fix: 合并服务器未推送改动并保留手动视频标签
This commit is contained in:
@@ -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";
|
||||
|
||||
import Link from "next/link";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
|
||||
export default function SttAdminPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<BackButton />
|
||||
|
||||
<div className="mt-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">STT 管理中心</h1>
|
||||
<p className="text-gray-600 mt-2">管理和配置视频语音转写功能</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6">
|
||||
{/* 视频转写管理 */}
|
||||
<Link
|
||||
href="/admin/stt/videos"
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
视频转写管理
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
|
||||
</p>
|
||||
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
|
||||
进入管理
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 转写设置(预留) */}
|
||||
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
转写设置
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
配置转写 API、模型参数和其他高级选项
|
||||
</p>
|
||||
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转写统计(预留) */}
|
||||
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
转写统计
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
查看转写使用情况、成功率、语言分布等统计数据
|
||||
</p>
|
||||
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "../asr/page";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import {
|
||||
isActive,
|
||||
@@ -18,6 +19,7 @@ type Video = {
|
||||
duration_ms: number;
|
||||
author: { nickname: string };
|
||||
transcription: TranscriptionState;
|
||||
content_tags: string[];
|
||||
};
|
||||
const filters: ("all" | TranscriptionStatus)[] = [
|
||||
"all",
|
||||
@@ -36,6 +38,10 @@ export default function SttVideosPage() {
|
||||
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
const fetching = useRef(false);
|
||||
const fetchVideos = useCallback(async () => {
|
||||
if (fetching.current) return;
|
||||
@@ -104,6 +110,28 @@ export default function SttVideosPage() {
|
||||
setBatchSubmitting(false);
|
||||
}
|
||||
}
|
||||
async function saveTags(clear = false) {
|
||||
setSavingTags(true);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
awemeIds: [...selected],
|
||||
contentTags: clear ? [] : [tag],
|
||||
mode: clear ? "set" : "add",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存手动标签失败");
|
||||
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
|
||||
setSelected(new Set());
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
}
|
||||
}
|
||||
const count = (status: TranscriptionStatus) =>
|
||||
videos.filter((v) => v.transcription.status === status).length;
|
||||
const eligible = videos.filter(
|
||||
@@ -111,7 +139,9 @@ export default function SttVideosPage() {
|
||||
v.transcription.transcript === null && !isActive(v.transcription.status),
|
||||
).length;
|
||||
const shown = videos.filter(
|
||||
(v) => filter === "all" || v.transcription.status === filter,
|
||||
(v) =>
|
||||
(filter === "all" || v.transcription.status === filter) &&
|
||||
(!tagFilter || v.content_tags.includes(tagFilter)),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -155,10 +185,70 @@ export default function SttVideosPage() {
|
||||
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
|
||||
<select
|
||||
aria-label="筛选手动标签"
|
||||
value={tagFilter}
|
||||
onChange={(e) => {
|
||||
setTagFilter(e.target.value);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className="rounded border p-2"
|
||||
>
|
||||
<option value="">全部手动标签</option>
|
||||
{VIDEO_CONTENT_TAGS.map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
<span>已选 {selected.size} 个(最多 200 个)</span>
|
||||
<select
|
||||
aria-label="批量添加的标签"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
className="rounded border p-2"
|
||||
>
|
||||
{VIDEO_CONTENT_TAGS.map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selected.size || savingTags}
|
||||
onClick={() => saveTags()}
|
||||
className="rounded border px-3 py-2 disabled:opacity-50"
|
||||
>
|
||||
添加手动标签
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selected.size || savingTags}
|
||||
onClick={() => saveTags(true)}
|
||||
className="rounded border px-3 py-2 disabled:opacity-50"
|
||||
>
|
||||
清空手动标签
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-100 text-gray-600">
|
||||
<tr>
|
||||
<th className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前列表前200个视频"
|
||||
checked={
|
||||
shown.length > 0 &&
|
||||
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
e.target.checked
|
||||
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
|
||||
: new Set(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3">视频</th>
|
||||
<th className="p-3">时长</th>
|
||||
<th className="p-3">状态与文字</th>
|
||||
@@ -175,6 +265,24 @@ export default function SttVideosPage() {
|
||||
key={video.aweme_id}
|
||||
className="border-t border-gray-100 align-top"
|
||||
>
|
||||
<td className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择视频 ${video.aweme_id}`}
|
||||
checked={selected.has(video.aweme_id)}
|
||||
disabled={
|
||||
!selected.has(video.aweme_id) && selected.size >= 200
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (e.target.checked) next.add(video.aweme_id);
|
||||
else next.delete(video.aweme_id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex max-w-sm gap-3">
|
||||
{video.cover_url && (
|
||||
@@ -197,6 +305,9 @@ export default function SttVideosPage() {
|
||||
<p className="mt-1 text-gray-500">
|
||||
@{video.author.nickname}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-blue-700">
|
||||
{video.content_tags.join("、")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from "../../stt/videos/route";
|
||||
@@ -31,6 +31,7 @@ export async function GET() {
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
content_tags: v.manual_content_tags,
|
||||
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 { prisma } from "@/lib/prisma";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
@@ -11,24 +12,45 @@ export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limitParam = searchParams.get("limit");
|
||||
const beforeParam = searchParams.get("before");
|
||||
const rawTag = (searchParams.get("tag") || "").trim();
|
||||
const selectedTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
|
||||
|
||||
if (rawTag && !selectedTag) {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||
const before = beforeParam ? new Date(beforeParam) : null;
|
||||
const videoWhere = {
|
||||
...(before ? { created_at: { lt: before } } : {}),
|
||||
...(selectedTag
|
||||
? {
|
||||
manual_content_tags: { has: selectedTag },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
// fetch chunk from both tables
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
where: videoWhere,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
}),
|
||||
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[] = [
|
||||
@@ -47,29 +69,33 @@ export async function GET(req: NextRequest) {
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.manual_content_tags,
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
return {
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
};
|
||||
}),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||
.slice(0, limit);
|
||||
|
||||
const nextCursor =
|
||||
merged.length > 0
|
||||
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
||||
? new Date(merged[merged.length - 1].created_at).toISOString()
|
||||
: null;
|
||||
const payload: FeedResponse = { items: merged, nextCursor };
|
||||
return NextResponse.json(payload);
|
||||
|
||||
+133
-80
@@ -1,11 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { json } from "@/lib/json";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type SearchRow = {
|
||||
id: string;
|
||||
awemeId: string;
|
||||
type: "video" | "image";
|
||||
rank: number;
|
||||
snippet: string;
|
||||
contentTags: string[];
|
||||
};
|
||||
|
||||
function sanitizeSnippetHtml(snippet: string) {
|
||||
return snippet
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("<mark>", "<mark>")
|
||||
.replaceAll("</mark>", "</mark>");
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = (searchParams.get("q") || "").trim();
|
||||
const rawTag = (searchParams.get("tag") || "").trim();
|
||||
const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
|
||||
const page = Math.max(1, Number(searchParams.get("page") || 1));
|
||||
const limit = Math.min(
|
||||
50,
|
||||
@@ -13,7 +34,11 @@ export async function GET(req: Request) {
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (!q) {
|
||||
if (rawTag && !contentTag) {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!q && !contentTag) {
|
||||
return NextResponse.json({ results: [], total: 0 });
|
||||
}
|
||||
|
||||
@@ -23,90 +48,117 @@ export async function GET(req: Request) {
|
||||
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
|
||||
// - 使用 UNION ALL 合并视频和图文的搜索结果
|
||||
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
|
||||
const rows = await prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
awemeId: string;
|
||||
type: "video" | "image";
|
||||
rank: number;
|
||||
snippet: string;
|
||||
}[]
|
||||
>`
|
||||
WITH tsq AS (
|
||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||
)
|
||||
-- 视频搜索结果
|
||||
SELECT
|
||||
COALESCE(vt.id, 'desc:' || v.aweme_id) AS id,
|
||||
v.aweme_id AS "awemeId",
|
||||
'video'::text AS type,
|
||||
GREATEST(
|
||||
COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0),
|
||||
COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0)
|
||||
) AS rank,
|
||||
CASE
|
||||
WHEN vt.transcript_zh_tsv @@ tsq.query THEN
|
||||
const rows = q
|
||||
? await prisma.$queryRaw<SearchRow[]>`
|
||||
WITH tsq AS (
|
||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||
)
|
||||
-- 视频搜索结果
|
||||
SELECT
|
||||
COALESCE(vt.id, 'desc:' || v.aweme_id) AS id,
|
||||
v.aweme_id AS "awemeId",
|
||||
'video'::text AS type,
|
||||
GREATEST(
|
||||
COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0),
|
||||
COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0)
|
||||
) AS rank,
|
||||
CASE
|
||||
WHEN vt.transcript_zh_tsv @@ tsq.query THEN
|
||||
ts_headline(
|
||||
'zhcfg',
|
||||
array_to_string(vt.transcript, ' '),
|
||||
tsq.query,
|
||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||
)
|
||||
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(
|
||||
'zhcfg',
|
||||
array_to_string(vt.transcript, ' '),
|
||||
ip.desc,
|
||||
tsq.query,
|
||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||
)
|
||||
ELSE
|
||||
ts_headline(
|
||||
'zhcfg',
|
||||
) AS snippet,
|
||||
ARRAY[]::text[] AS "contentTags"
|
||||
FROM "ImagePost" ip, tsq
|
||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
||||
AND ${contentTag}::text IS NULL
|
||||
|
||||
ORDER BY rank DESC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${limit};
|
||||
`
|
||||
: await prisma.$queryRaw<SearchRow[]>`
|
||||
SELECT
|
||||
COALESCE(vt.id, 'tag:' || v.aweme_id) AS id,
|
||||
v.aweme_id AS "awemeId",
|
||||
'video'::text AS type,
|
||||
1.0::double precision AS rank,
|
||||
COALESCE(
|
||||
NULLIF(array_to_string(vt.transcript[1:2], ' '), ''),
|
||||
v.desc,
|
||||
tsq.query,
|
||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||
)
|
||||
END AS snippet
|
||||
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)
|
||||
|
||||
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};
|
||||
`;
|
||||
'标签匹配'
|
||||
) AS snippet,
|
||||
v.manual_content_tags AS "contentTags"
|
||||
FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
ORDER BY v.created_at DESC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${limit};
|
||||
`;
|
||||
|
||||
// 查询总数(参数化,避免注入)
|
||||
const totalRows = await prisma.$queryRaw<
|
||||
{
|
||||
count: number;
|
||||
}[]
|
||||
>`
|
||||
WITH tsq AS (
|
||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||
)
|
||||
SELECT (
|
||||
(SELECT COUNT(*)::int FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query))
|
||||
+
|
||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query)
|
||||
) AS count
|
||||
FROM tsq;
|
||||
`;
|
||||
const totalRows = q
|
||||
? await prisma.$queryRaw<{ count: number }[]>`
|
||||
WITH tsq AS (
|
||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||
)
|
||||
SELECT (
|
||||
(SELECT COUNT(*)::int FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
||||
AND CASE
|
||||
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||
ELSE v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
END)
|
||||
+
|
||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
||||
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
|
||||
const videoIds = rows
|
||||
@@ -176,6 +228,7 @@ export async function GET(req: Request) {
|
||||
return json({
|
||||
results: rows.map((r) => ({
|
||||
...r,
|
||||
snippet: sanitizeSnippetHtml(r.snippet),
|
||||
video:
|
||||
r.type === "video"
|
||||
? videos.find((v) => v.aweme_id === r.awemeId)
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Play } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import type {
|
||||
AwemeData,
|
||||
ImageData,
|
||||
Neighbors,
|
||||
VideoData,
|
||||
VideoTranscript,
|
||||
} from "./types.ts";
|
||||
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
||||
import { CommentPanel } from "./components/CommentPanel";
|
||||
import { ImageCarousel } from "./components/ImageCarousel";
|
||||
@@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls";
|
||||
import { NavigationButtons } from "./components/NavigationButtons";
|
||||
import { TranscriptPanel } from "./components/TranscriptPanel";
|
||||
import { VideoPlayer } from "./components/VideoPlayer";
|
||||
import { VideoTagEditor } from "./components/VideoTagEditor";
|
||||
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
||||
import { useCommentState } from "./hooks/useCommentState";
|
||||
import { useImageCarousel } from "./hooks/useImageCarousel";
|
||||
import { useNavigation } from "./hooks/useNavigation";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||
import type {
|
||||
AwemeData,
|
||||
ImageData,
|
||||
Neighbors,
|
||||
VideoData,
|
||||
VideoTranscript,
|
||||
} from "./types.ts";
|
||||
|
||||
const SEGMENT_MS = 4000;
|
||||
|
||||
type WebkitVideoElement = HTMLVideoElement & {
|
||||
webkitEnterFullscreen?: () => void;
|
||||
};
|
||||
|
||||
type MediaPositionStateInput = {
|
||||
duration: number;
|
||||
position: number;
|
||||
playbackRate?: number;
|
||||
};
|
||||
|
||||
type MediaSessionWithPositionState = MediaSession & {
|
||||
setPositionState?: (state: MediaPositionStateInput) => void;
|
||||
};
|
||||
|
||||
interface AwemeDetailClientProps {
|
||||
data: AwemeData;
|
||||
neighbors: Neighbors;
|
||||
@@ -45,6 +60,9 @@ export default function AwemeDetailClient({
|
||||
const playerState = usePlayerState();
|
||||
const commentState = useCommentState();
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||
const [contentTags, setContentTags] = useState<string[]>(
|
||||
isVideo ? (data as VideoData).content_tags : [],
|
||||
);
|
||||
|
||||
// 引用
|
||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -68,6 +86,7 @@ export default function AwemeDetailClient({
|
||||
|
||||
// 图文轮播状态
|
||||
const images = isVideo ? [] : (data as ImageData).images;
|
||||
const imageCount = images.length;
|
||||
const imageCarouselState = useImageCarousel({
|
||||
images,
|
||||
isPlaying: playerState.isPlaying,
|
||||
@@ -80,40 +99,42 @@ export default function AwemeDetailClient({
|
||||
});
|
||||
|
||||
// 视频播放器 hooks
|
||||
if (isVideo) {
|
||||
useVideoPlayer({
|
||||
awemeId: data.aweme_id,
|
||||
videoRef,
|
||||
volume: playerState.volume,
|
||||
rate: playerState.rate,
|
||||
loopMode: playerState.loopMode,
|
||||
neighbors,
|
||||
progressRestored: playerState.progressRestored,
|
||||
setIsPlaying: playerState.setIsPlaying,
|
||||
setProgress: playerState.setProgress,
|
||||
setProgressRestored: playerState.setProgressRestored,
|
||||
});
|
||||
}
|
||||
useVideoPlayer({
|
||||
enabled: isVideo,
|
||||
awemeId: data.aweme_id,
|
||||
videoRef,
|
||||
volume: playerState.volume,
|
||||
rate: playerState.rate,
|
||||
loopMode: playerState.loopMode,
|
||||
neighbors,
|
||||
progressRestored: playerState.progressRestored,
|
||||
setIsPlaying: playerState.setIsPlaying,
|
||||
setProgress: playerState.setProgress,
|
||||
setProgressRestored: playerState.setProgressRestored,
|
||||
});
|
||||
|
||||
// 媒体操作函数
|
||||
const seekTo = (ratio: number) => {
|
||||
ratio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * ratio;
|
||||
return;
|
||||
}
|
||||
if (!images?.length) return;
|
||||
imageCarouselState.seekTo(ratio);
|
||||
};
|
||||
const seekTo = useCallback(
|
||||
(ratio: number) => {
|
||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * clampedRatio;
|
||||
return;
|
||||
}
|
||||
if (!imageCount) return;
|
||||
imageCarouselState.seekTo(clampedRatio);
|
||||
},
|
||||
[imageCarouselState.seekTo, imageCount, isVideo],
|
||||
);
|
||||
|
||||
const seekImageByVisualRatio = (ratio: number) => {
|
||||
if (!images?.length) return;
|
||||
if (!imageCount) return;
|
||||
|
||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||
const rawSegment = clampedRatio * images.length;
|
||||
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
|
||||
const rawSegment = clampedRatio * imageCount;
|
||||
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
|
||||
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
||||
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
||||
};
|
||||
@@ -152,14 +173,12 @@ export default function AwemeDetailClient({
|
||||
document.body.requestFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const vRef = videoRef.current;
|
||||
if (vRef && vRef.requestFullscreen) {
|
||||
const vRef = videoRef.current as WebkitVideoElement | null;
|
||||
if (vRef?.requestFullscreen) {
|
||||
vRef.requestFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (vRef && vRef.webkitEnterFullscreen) {
|
||||
// @ts-ignore
|
||||
if (vRef?.webkitEnterFullscreen) {
|
||||
vRef.webkitEnterFullscreen();
|
||||
}
|
||||
} else {
|
||||
@@ -167,20 +186,20 @@ export default function AwemeDetailClient({
|
||||
}
|
||||
};
|
||||
|
||||
const prevImg = () => {
|
||||
if (!images?.length) return;
|
||||
const prevImg = useCallback(() => {
|
||||
if (!imageCount) return;
|
||||
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
||||
imageCarouselState.goToIndex(next);
|
||||
};
|
||||
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||
|
||||
const nextImg = () => {
|
||||
if (!images?.length) return;
|
||||
const nextImg = useCallback(() => {
|
||||
if (!imageCount) return;
|
||||
const next = Math.min(
|
||||
images.length - 1,
|
||||
imageCount - 1,
|
||||
imageCarouselState.idxRef.current + 1,
|
||||
);
|
||||
imageCarouselState.goToIndex(next);
|
||||
};
|
||||
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (isVideo) {
|
||||
@@ -249,7 +268,7 @@ export default function AwemeDetailClient({
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
|
||||
const ms = (navigator as any).mediaSession as MediaSession;
|
||||
const ms = navigator.mediaSession as MediaSessionWithPositionState;
|
||||
|
||||
// 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像)
|
||||
const title = data.desc || `作品 ${data.aweme_id}`;
|
||||
@@ -282,12 +301,12 @@ export default function AwemeDetailClient({
|
||||
|
||||
const updatePosition = () => {
|
||||
try {
|
||||
// @ts-ignore: setPositionState 可能不存在
|
||||
if (!ms.setPositionState) return;
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !isFinite(v.duration) || isNaN(v.duration)) return;
|
||||
// @ts-ignore
|
||||
if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) {
|
||||
return;
|
||||
}
|
||||
ms.setPositionState({
|
||||
duration: Math.max(0, v.duration),
|
||||
position: Math.max(0, v.currentTime),
|
||||
@@ -300,14 +319,12 @@ export default function AwemeDetailClient({
|
||||
0,
|
||||
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
||||
);
|
||||
// @ts-ignore
|
||||
ms.setPositionState({ duration, position, playbackRate: 1 });
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// 绑定视频事件以同步状态
|
||||
let timeUpdateTimer: number | null = null;
|
||||
const v = videoRef.current;
|
||||
if (isVideo && v) {
|
||||
const onPlay = () => {
|
||||
@@ -331,7 +348,6 @@ export default function AwemeDetailClient({
|
||||
updatePosition();
|
||||
|
||||
// 清理
|
||||
timeUpdateTimer = null;
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
@@ -346,15 +362,10 @@ export default function AwemeDetailClient({
|
||||
}
|
||||
// 依赖:当这些变化时刷新元信息与位置状态
|
||||
}, [
|
||||
isVideo,
|
||||
data.aweme_id,
|
||||
data.desc,
|
||||
data.author.nickname,
|
||||
data.author.avatar_url,
|
||||
data.created_at,
|
||||
imageCarouselState.idx,
|
||||
data,
|
||||
imageCarouselState.totalDurationMs,
|
||||
images,
|
||||
isVideo,
|
||||
playerState.isPlaying,
|
||||
playerState.progress,
|
||||
playerState.rate,
|
||||
@@ -364,7 +375,7 @@ export default function AwemeDetailClient({
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const ms = (navigator as any).mediaSession as MediaSession;
|
||||
const ms = navigator.mediaSession;
|
||||
|
||||
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
||||
|
||||
@@ -421,7 +432,7 @@ export default function AwemeDetailClient({
|
||||
const handleSeekTo = (seekTimeSec: number) => {
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (v && isFinite(v.duration)) {
|
||||
if (v && Number.isFinite(v.duration)) {
|
||||
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
|
||||
}
|
||||
} else if (images?.length) {
|
||||
@@ -434,13 +445,13 @@ export default function AwemeDetailClient({
|
||||
ms.setActionHandler("play", handlePlay);
|
||||
ms.setActionHandler("pause", handlePause);
|
||||
ms.setActionHandler("stop", handleStop);
|
||||
ms.setActionHandler("seekbackward", (details: any) =>
|
||||
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
|
||||
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
||||
);
|
||||
ms.setActionHandler("seekforward", (details: any) =>
|
||||
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
|
||||
handleSeekDelta((details?.seekOffset as number) || 10),
|
||||
);
|
||||
ms.setActionHandler("seekto", (details: any) => {
|
||||
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
|
||||
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
||||
});
|
||||
ms.setActionHandler("previoustrack", () => {
|
||||
@@ -477,8 +488,12 @@ export default function AwemeDetailClient({
|
||||
neighbors.next,
|
||||
router,
|
||||
playerState.progress,
|
||||
playerState.setIsPlaying,
|
||||
pauseImageMedia,
|
||||
imageCarouselState.totalDurationMs,
|
||||
nextImg,
|
||||
prevImg,
|
||||
seekTo,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -537,6 +552,14 @@ export default function AwemeDetailClient({
|
||||
)}
|
||||
|
||||
{/* 媒体控制栏 */}
|
||||
{isVideo && (
|
||||
<VideoTagEditor
|
||||
awemeId={data.aweme_id}
|
||||
tags={contentTags}
|
||||
onTagsChange={setContentTags}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaControls
|
||||
isVideo={isVideo}
|
||||
isPlaying={playerState.isPlaying}
|
||||
|
||||
@@ -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 type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { LoopMode, Neighbors } from "../types.ts";
|
||||
|
||||
interface UseVideoPlayerProps {
|
||||
enabled?: boolean;
|
||||
awemeId: string;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
volume: number;
|
||||
@@ -17,6 +18,7 @@ interface UseVideoPlayerProps {
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
enabled = true,
|
||||
awemeId,
|
||||
videoRef,
|
||||
volume,
|
||||
@@ -32,6 +34,7 @@ export function useVideoPlayer({
|
||||
|
||||
// 恢复播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (progressRestored) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
@@ -74,10 +77,11 @@ export function useVideoPlayer({
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
}
|
||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
||||
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||
|
||||
// 保存播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -105,10 +109,11 @@ export function useVideoPlayer({
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
saveProgress();
|
||||
};
|
||||
}, [awemeId, videoRef]);
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
// 监听播放状态和进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -137,10 +142,19 @@ export function useVideoPlayer({
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
||||
}, [
|
||||
enabled,
|
||||
loopMode,
|
||||
neighbors?.next,
|
||||
router,
|
||||
videoRef,
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
]);
|
||||
|
||||
// 自动播放检测
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -148,7 +162,7 @@ export function useVideoPlayer({
|
||||
try {
|
||||
await v.play();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.log("自动播放被阻止,需要用户交互");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
@@ -160,16 +174,18 @@ export function useVideoPlayer({
|
||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||
}
|
||||
}, [awemeId, videoRef, setIsPlaying]);
|
||||
}, [enabled, videoRef, setIsPlaying]);
|
||||
|
||||
// 更新音量和倍速
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, videoRef]);
|
||||
}, [enabled, volume, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, videoRef]);
|
||||
}, [enabled, rate, videoRef]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { Metadata } from "next";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { AwemeData, VideoTranscript } from "./types";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { AwemeData, VideoTranscript } from "./types";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -53,7 +53,7 @@ export default async function AwemeDetail({
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true },
|
||||
include: { author: true, transcript: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
@@ -63,65 +63,73 @@ export default async function AwemeDetail({
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const isVideo = Boolean(video);
|
||||
|
||||
// 获取评论总数
|
||||
const commentsCount = await prisma.comment.count({
|
||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||
});
|
||||
const aweme = isVideo ? video : post;
|
||||
|
||||
const data: AwemeData = {
|
||||
aweme_id: aweme!.aweme_id,
|
||||
desc: aweme!.desc,
|
||||
created_at: aweme!.created_at,
|
||||
likesCount: Number(aweme!.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: aweme!.author.nickname,
|
||||
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: aweme!.author.sec_uid,
|
||||
},
|
||||
...(() => {
|
||||
if (isVideo) {
|
||||
const aweme = video!;
|
||||
return {
|
||||
type: "video" as const,
|
||||
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
||||
duration_ms: aweme!.duration_ms,
|
||||
video_url: getFileUrl(aweme!.video_url),
|
||||
width: aweme!.width ?? null,
|
||||
height: aweme!.height ?? null,
|
||||
};
|
||||
} else {
|
||||
const aweme = post!;
|
||||
return {
|
||||
type: "image" as const,
|
||||
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
|
||||
cover_size: {
|
||||
h: aweme!.images[0].height ?? 0,
|
||||
w: aweme!.images[0].width ?? 0,
|
||||
},
|
||||
images: aweme!.images.map((img) => ({
|
||||
...img,
|
||||
url: getFileUrl(img.url),
|
||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||
})),
|
||||
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
|
||||
};
|
||||
}
|
||||
})(),
|
||||
};
|
||||
let data: AwemeData;
|
||||
let currentCreatedAt: Date;
|
||||
let transcript: VideoTranscript | null = null;
|
||||
|
||||
const transcript: VideoTranscript | null = isVideo
|
||||
? await prisma.videoTranscript.findUnique({
|
||||
where: { videoId: id },
|
||||
})
|
||||
: null;
|
||||
if (video) {
|
||||
transcript = video.transcript ?? null;
|
||||
currentCreatedAt = video.created_at;
|
||||
data = {
|
||||
type: "video",
|
||||
aweme_id: video.aweme_id,
|
||||
desc: video.desc,
|
||||
created_at: video.created_at,
|
||||
likesCount: Number(video.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: video.author.nickname,
|
||||
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: video.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||
duration_ms: video.duration_ms,
|
||||
video_url: getFileUrl(video.video_url),
|
||||
width: video.width ?? null,
|
||||
height: video.height ?? null,
|
||||
content_tags: 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
|
||||
const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
|
||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||
prisma.video.findFirst({
|
||||
where: { created_at: { gt: currentCreatedAt } },
|
||||
|
||||
@@ -24,6 +24,8 @@ export type VideoData = {
|
||||
video_url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
author: User;
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
|
||||
+265
-93
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ThumbsUp, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import HoverVideo from "./HoverVideo";
|
||||
import { ThumbsUp } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
import HoverVideo from "./HoverVideo";
|
||||
|
||||
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
|
||||
|
||||
function getAspectRatio(item: FeedItem) {
|
||||
return item.width && item.height ? `${item.width}/${item.height}` : undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialItems: FeedItem[];
|
||||
@@ -11,6 +19,13 @@ type Props = {
|
||||
fetchUrl?: string;
|
||||
};
|
||||
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function FeedMasonry({
|
||||
initialItems,
|
||||
initialCursor,
|
||||
@@ -20,6 +35,10 @@ export default function FeedMasonry({
|
||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ended, setEnded] = useState(!initialCursor);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchTag, setBatchTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -58,7 +77,7 @@ export default function FeedMasonry({
|
||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||
}
|
||||
// 文本 + 作者栏的高度粗估
|
||||
const textH = 48; // 标题+标签区域
|
||||
const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域
|
||||
const authorH = 48; // 作者行
|
||||
const gap = 16; // 卡片内边距/间隙
|
||||
return mediaH + textH + authorH + gap;
|
||||
@@ -92,8 +111,7 @@ export default function FeedMasonry({
|
||||
}
|
||||
setColumns(newCols);
|
||||
setColHeights(newHeights);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnCount]);
|
||||
}, [columnCount, estimateItemHeight, initialItems]);
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (loading || ended) return;
|
||||
@@ -136,7 +154,15 @@ export default function FeedMasonry({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
||||
}, [
|
||||
cursor,
|
||||
ended,
|
||||
loading,
|
||||
columnCount,
|
||||
colHeights,
|
||||
estimateItemHeight,
|
||||
fetchUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
@@ -154,95 +180,193 @@ export default function FeedMasonry({
|
||||
return () => io.disconnect();
|
||||
}, [fetchMore]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className="mb-4 group relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio:
|
||||
`${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>
|
||||
const toggleSelection = useCallback((awemeId: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(awemeId)) next.delete(awemeId);
|
||||
else next.add(awemeId);
|
||||
return next;
|
||||
});
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<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>
|
||||
</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 clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
const applyUpdatedTags = useCallback(
|
||||
(updates: TagUpdateResponse["updated"]) => {
|
||||
if (!updates?.length) return;
|
||||
const tagMap = new Map(
|
||||
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||
);
|
||||
setColumns((currentColumns) =>
|
||||
currentColumns.map((column) =>
|
||||
column.map((item) =>
|
||||
tagMap.has(item.aweme_id)
|
||||
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||
: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBatchAddTag = useCallback(async () => {
|
||||
const awemeIds = [...selectedIds];
|
||||
if (awemeIds.length === 0 || batchSaving) return;
|
||||
setBatchSaving(true);
|
||||
setBatchError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
|
||||
});
|
||||
if (!response.ok) throw new Error("批量添加标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
applyUpdatedTags(data.updated);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
setBatchError(
|
||||
error instanceof Error ? error.message : "批量添加标签失败",
|
||||
);
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => {
|
||||
const selected = selectedIds.has(item.aweme_id);
|
||||
return (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
|
||||
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
|
||||
}`}
|
||||
>
|
||||
{item.type === "video" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSelection(item.aweme_id)}
|
||||
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
|
||||
selected
|
||||
? "border-blue-300 bg-blue-600"
|
||||
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||
>
|
||||
{selected && <Check size={17} />}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio: getAspectRatio(item),
|
||||
}}
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<HoverVideo
|
||||
videoUrl={item.video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
fill
|
||||
src={item.cover_url || "/placeholder.svg"}
|
||||
alt={item.desc?.slice(0, 20) || "image"}
|
||||
unoptimized
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
{!!item.content_tags?.length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||
{item.likes}{" "}
|
||||
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
[selectedIds, toggleSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||
@@ -254,7 +378,7 @@ export default function FeedMasonry({
|
||||
}}
|
||||
>
|
||||
{columns.map((col, idx) => (
|
||||
<div key={idx} className="flex flex-col">
|
||||
<div key={COLUMN_KEYS[idx]} className="flex flex-col">
|
||||
{col.map((item) => renderCard(item))}
|
||||
</div>
|
||||
))}
|
||||
@@ -265,6 +389,54 @@ export default function FeedMasonry({
|
||||
>
|
||||
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
已选择 {selectedIds.size} 个视频
|
||||
</span>
|
||||
{batchError && (
|
||||
<span className="text-xs text-rose-300">{batchError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
|
||||
aria-label="选择要添加的标签"
|
||||
>
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => (
|
||||
<option
|
||||
key={tag}
|
||||
value={tag}
|
||||
className="bg-zinc-900 text-white"
|
||||
>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchAddTag}
|
||||
disabled={batchSaving}
|
||||
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{batchSaving ? "添加中..." : "添加标签"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
aria-label="清空选择"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SearchBox() {
|
||||
export default function SearchBox({ tag }: { tag?: string | null }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
@@ -12,7 +12,9 @@ export default function SearchBox() {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
const params = new URLSearchParams({ q: trimmed });
|
||||
if (tag) params.set("tag", tag);
|
||||
router.push(`/search?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: "记录当下时代的精彩瞬间",
|
||||
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 FeedMasonry from "./components/FeedMasonry";
|
||||
import SearchBox from "./components/SearchBox";
|
||||
import type { FeedItem } from "./types/feed";
|
||||
import type { Metadata } from "next";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,18 +14,38 @@ export const metadata: Metadata = {
|
||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { tag?: string };
|
||||
}) {
|
||||
const requestedTag = searchParams?.tag?.trim() || "";
|
||||
const selectedTag =
|
||||
requestedTag && isVideoContentTag(requestedTag) ? requestedTag : null;
|
||||
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: selectedTag
|
||||
? {
|
||||
manual_content_tags: { has: selectedTag },
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
}),
|
||||
selectedTag
|
||||
? Promise.resolve([])
|
||||
: prisma.imagePost.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: {
|
||||
author: true,
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const feed: FeedItem[] = [
|
||||
@@ -42,22 +64,26 @@ export default async function Home() {
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.manual_content_tags,
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
return {
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
};
|
||||
}),
|
||||
]
|
||||
//.sort(() => Math.random() - 0.5)
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||
@@ -75,20 +101,53 @@ export default async function Home() {
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-xl mb-12">
|
||||
<SearchBox />
|
||||
<SearchBox tag={selectedTag} />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex w-full max-w-4xl flex-wrap justify-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedTag
|
||||
? "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300"
|
||||
: "border-blue-500 bg-blue-600 text-white"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</Link>
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/?tag=${encodeURIComponent(tag)}`}
|
||||
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedTag === tag
|
||||
? "border-blue-500 bg-blue-600 text-white"
|
||||
: "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{(() => {
|
||||
const initial = feed.slice(0, 24);
|
||||
const cursor =
|
||||
initial.length > 0
|
||||
? new Date(
|
||||
initial[initial.length - 1].created_at as any,
|
||||
).toISOString()
|
||||
: null;
|
||||
const lastItem = initial.at(-1);
|
||||
const cursor = lastItem
|
||||
? new Date(lastItem.created_at).toISOString()
|
||||
: null;
|
||||
return (
|
||||
<FeedMasonry initialItems={initial} initialCursor={cursor} />
|
||||
<FeedMasonry
|
||||
key={selectedTag ?? "all"}
|
||||
initialItems={initial}
|
||||
initialCursor={cursor}
|
||||
fetchUrl={
|
||||
selectedTag
|
||||
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
|
||||
: "/api/feed"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
+116
-29
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { ArrowLeft, MessageSquare, Search, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags";
|
||||
|
||||
// 匹配搜索结果类型(适配后端API)
|
||||
type SearchResultItem = {
|
||||
@@ -12,6 +14,7 @@ type SearchResultItem = {
|
||||
type: "video" | "image";
|
||||
rank: number;
|
||||
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
||||
contentTags: string[];
|
||||
video?: {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
@@ -47,9 +50,20 @@ export default function SearchClient({
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
|
||||
const performSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
const buildSearchHref = useCallback((q: string, tag: string | null) => {
|
||||
const params = new URLSearchParams();
|
||||
const trimmed = q.trim();
|
||||
if (trimmed) params.set("q", trimmed);
|
||||
if (tag) params.set("tag", tag);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/search?${queryString}` : "/search";
|
||||
}, []);
|
||||
|
||||
const performSearch = useCallback(async (q: string, tag: string | null) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed && !tag) {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
return;
|
||||
@@ -57,9 +71,10 @@ export default function SearchClient({
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`,
|
||||
);
|
||||
const params = new URLSearchParams({ limit: "60" });
|
||||
if (trimmed) params.set("q", trimmed);
|
||||
if (tag) params.set("tag", tag);
|
||||
const res = await fetch(`/api/search?${params.toString()}`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
@@ -74,32 +89,64 @@ export default function SearchClient({
|
||||
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q") || "";
|
||||
const tagParam = searchParams.get("tag") || "";
|
||||
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||
setQuery(q);
|
||||
if (q) performSearch(q);
|
||||
setSelectedTag(tag);
|
||||
if (q || tag) {
|
||||
performSearch(q, tag);
|
||||
} else {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
}
|
||||
}, [searchParams, performSearch]);
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
// 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索
|
||||
// performSearch(trimmed);
|
||||
if (!trimmed && !selectedTag) return;
|
||||
router.push(buildSearchHref(trimmed, selectedTag));
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: string) => {
|
||||
const nextTag = selectedTag === tag ? null : tag;
|
||||
setSelectedTag(nextTag);
|
||||
router.push(buildSearchHref(query, nextTag));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery("");
|
||||
setSelectedTag(null);
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
router.push("/search");
|
||||
};
|
||||
|
||||
const renderSnippet = (snippet: string) => {
|
||||
let highlighted = false;
|
||||
let offset = 0;
|
||||
return snippet.split(/(<mark>|<\/mark>)/g).map((part) => {
|
||||
offset += part.length;
|
||||
if (part === "<mark>") {
|
||||
highlighted = true;
|
||||
return null;
|
||||
}
|
||||
if (part === "</mark>") {
|
||||
highlighted = false;
|
||||
return null;
|
||||
}
|
||||
if (!part) return null;
|
||||
return highlighted ? <mark key={`${offset}-${part}`}>{part}</mark> : part;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
{/* 顶部搜索栏 */}
|
||||
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="返回首页"
|
||||
@@ -118,13 +165,12 @@ export default function SearchClient({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索视频、图文描述或语音内容..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
||||
/>
|
||||
{query && (
|
||||
{(query || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
@@ -138,12 +184,30 @@ export default function SearchClient({
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || !query.trim()}
|
||||
disabled={loading || (!query.trim() && !selectedTag)}
|
||||
>
|
||||
{loading ? "搜索中..." : "搜索"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 pb-4">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
className={`flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
selectedTag === tag
|
||||
? "border-blue-400 bg-blue-500/20 text-blue-200"
|
||||
: "border-white/10 bg-white/5 text-white/60 hover:border-white/20 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 结果区域 */}
|
||||
@@ -165,7 +229,7 @@ export default function SearchClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !searched && !query && (
|
||||
{!loading && !searched && !query && !selectedTag && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
||||
<Search size={64} className="mb-4 opacity-20" />
|
||||
<p className="text-xl">输入关键词开始搜索</p>
|
||||
@@ -207,11 +271,18 @@ export default function SearchClient({
|
||||
target="_blank"
|
||||
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
||||
>
|
||||
<img
|
||||
src={content.cover_url || ""}
|
||||
alt={content.desc}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
{content.cover_url ? (
|
||||
<Image
|
||||
src={content.cover_url}
|
||||
alt={content.desc}
|
||||
width={280}
|
||||
height={384}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-zinc-800" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* 右侧:信息区 */}
|
||||
@@ -231,6 +302,14 @@ export default function SearchClient({
|
||||
<MessageSquare size={12} />
|
||||
匹配度: {(item.rank * 100).toFixed(1)}%
|
||||
</span>
|
||||
{item.contentTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-200"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
@@ -245,11 +324,18 @@ export default function SearchClient({
|
||||
|
||||
{/* 作者信息 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<img
|
||||
src={content.author.avatar_url || ""}
|
||||
alt={content.author.nickname}
|
||||
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
||||
/>
|
||||
{content.author.avatar_url ? (
|
||||
<Image
|
||||
src={content.author.avatar_url}
|
||||
alt={content.author.nickname}
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full bg-white/10 ring-1 ring-white/20" />
|
||||
)}
|
||||
<span className="text-xs text-white/60">
|
||||
{content.author.nickname}
|
||||
</span>
|
||||
@@ -264,11 +350,12 @@ export default function SearchClient({
|
||||
</h4>
|
||||
<div
|
||||
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: item.snippet }}
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{renderSnippet(item.snippet)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ export type FeedItem = (
|
||||
cover_url: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
content_tags?: string[];
|
||||
};
|
||||
|
||||
export interface FeedResponse {
|
||||
|
||||
Reference in New Issue
Block a user