完成转录功能,中文脚本搜索,播放界面显示脚本
This commit is contained in:
parent
d1b4fb6be0
commit
0d18595b68
143
app/admin/stt/page.tsx
Normal file
143
app/admin/stt/page.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
424
app/admin/stt/videos/page.tsx
Normal file
424
app/admin/stt/videos/page.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import BackButton from '@/app/components/BackButton';
|
||||
|
||||
type VideoTranscript = {
|
||||
id: string;
|
||||
speech_detected: boolean;
|
||||
language: string | null;
|
||||
audio_type: string | null;
|
||||
transcript: string | null;
|
||||
non_speech_summary: string | null;
|
||||
};
|
||||
|
||||
type Video = {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string;
|
||||
duration_ms: number;
|
||||
author: {
|
||||
nickname: string;
|
||||
};
|
||||
transcript: VideoTranscript | null;
|
||||
};
|
||||
|
||||
type ApiResponse = {
|
||||
videos: Video[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export default function SttVideosPage() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState<'all' | 'transcribed' | 'pending'>('all');
|
||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/stt/videos');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch videos');
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
setVideos(data.videos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 简单的 sleep 工具
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
type PollOptions = {
|
||||
intervalMs?: number;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
|
||||
// 轮询某个视频的转写状态直至完成或超时
|
||||
const pollVideoUntilTranscribed = async (
|
||||
awemeId: string,
|
||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {}
|
||||
): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/stt/videos');
|
||||
if (resp.ok) {
|
||||
const data: ApiResponse = await resp.json();
|
||||
setVideos(data.videos);
|
||||
const v = data.videos.find((x) => x.aweme_id === awemeId);
|
||||
if (v && v.transcript) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略网络错误,继续轮询
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTranscribe = async (awemeId: string) => {
|
||||
setTranscribing(prev => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to transcribe video');
|
||||
}
|
||||
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
|
||||
const done = await pollVideoUntilTranscribed(awemeId);
|
||||
if (!done) {
|
||||
// 兜底刷新一次
|
||||
await fetchVideos();
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Transcription failed');
|
||||
} finally {
|
||||
setTranscribing(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(awemeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranscribe = async () => {
|
||||
const pendingVideos = videos.filter(v => v.transcript === null);
|
||||
if (pendingVideos.length === 0) {
|
||||
alert('没有待转写的视频');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchTranscribing(true);
|
||||
setBatchProgress({ current: 0, total: pendingVideos.length });
|
||||
|
||||
const pollers: Promise<boolean>[] = [];
|
||||
for (let i = 0; i < pendingVideos.length; i++) {
|
||||
const video = pendingVideos[i];
|
||||
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
||||
setTranscribing(prev => new Set(prev).add(video.aweme_id));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
||||
}
|
||||
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
|
||||
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
|
||||
setTranscribing(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(video.aweme_id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
pollers.push(p);
|
||||
} catch (err) {
|
||||
console.error(`Error transcribing video ${video.aweme_id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有轮询结束(完成或超时),再进行最终刷新
|
||||
if (pollers.length > 0) {
|
||||
await Promise.allSettled(pollers);
|
||||
}
|
||||
await fetchVideos();
|
||||
setBatchTranscribing(false);
|
||||
setBatchProgress({ current: 0, total: 0 });
|
||||
alert('批量转写完成!');
|
||||
};
|
||||
|
||||
const filteredVideos = videos.filter(v => {
|
||||
if (filter === 'transcribed') return v.transcript !== null;
|
||||
if (filter === 'pending') return v.transcript === null;
|
||||
return true;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: videos.length,
|
||||
transcribed: videos.filter(v => v.transcript !== null).length,
|
||||
pending: videos.filter(v => v.transcript === null).length,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p className="text-red-600">错误: {error}</p>
|
||||
<button
|
||||
onClick={fetchVideos}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mt-4">视频转写管理</h1>
|
||||
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-600">总视频数</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-600">已转写</div>
|
||||
<div className="text-3xl font-bold text-green-600 mt-2">{stats.transcribed}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-600">待转写</div>
|
||||
<div className="text-3xl font-bold text-orange-600 mt-2">{stats.pending}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
全部 ({stats.total})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('transcribed')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === 'transcribed'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
已转写 ({stats.transcribed})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('pending')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === 'pending'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
待转写 ({stats.pending})
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{batchTranscribing && (
|
||||
<div className="text-sm text-gray-600">
|
||||
正在转写: {batchProgress.current} / {batchProgress.total}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBatchTranscribe}
|
||||
disabled={batchTranscribing || stats.pending === 0}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{batchTranscribing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
批量转写中...
|
||||
</span>
|
||||
) : (
|
||||
'全部转写'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video List */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
视频
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
时长
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
转写状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredVideos.map((video) => (
|
||||
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={video.cover_url}
|
||||
alt=""
|
||||
className="w-20 h-28 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/aweme/${video.aweme_id}`}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
|
||||
>
|
||||
{video.desc || '无描述'}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
@{video.author.nickname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{Math.floor(video.duration_ms / 1000)}s
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{video.transcript ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
已转写
|
||||
</span>
|
||||
{video.transcript.language && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{video.transcript.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{video.transcript.speech_detected ? (
|
||||
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
||||
{video.transcript.transcript}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium">
|
||||
{video.transcript.audio_type || '非语音'}
|
||||
</span>
|
||||
{video.transcript.non_speech_summary && (
|
||||
<span className="ml-1">
|
||||
- {video.transcript.non_speech_summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||
待转写
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{video.transcript ? (
|
||||
<button
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
disabled={transcribing.has(video.aweme_id)}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{transcribing.has(video.aweme_id) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
重新转写...
|
||||
</span>
|
||||
) : (
|
||||
'重新转写'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
disabled={transcribing.has(video.aweme_id)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{transcribing.has(video.aweme_id) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
转写中...
|
||||
</span>
|
||||
) : (
|
||||
'开始转写'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredVideos.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
没有找到视频
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
app/api/admin/stt/videos/route.ts
Normal file
60
app/api/admin/stt/videos/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getFileUrl } from '@/lib/minio';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const videos = await prisma.video.findMany({
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 1000,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
transcript: {
|
||||
select: {
|
||||
id: true,
|
||||
speech_detected: true,
|
||||
language: true,
|
||||
audio_type: true,
|
||||
transcript: true,
|
||||
non_speech_summary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const formattedVideos = videos.map((v) => ({
|
||||
aweme_id: v.aweme_id,
|
||||
desc: v.desc,
|
||||
cover_url: getFileUrl(v.cover_url ?? ''),
|
||||
duration_ms: v.duration_ms,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
transcript: v.transcript
|
||||
? {
|
||||
id: v.transcript.id,
|
||||
speech_detected: v.transcript.speech_detected,
|
||||
language: v.transcript.language,
|
||||
audio_type: v.transcript.audio_type,
|
||||
transcript: v.transcript.transcript,
|
||||
non_speech_summary: v.transcript.non_speech_summary,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
videos: formattedVideos,
|
||||
total: videos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch videos:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch videos' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
|
||||
import chalk from 'chalk';
|
||||
import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser';
|
||||
import { extractFirstFrame } from '@/app/api/media';
|
||||
import { transcriptAweme } from '../stt';
|
||||
|
||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
||||
@ -308,6 +309,7 @@ export async function scrapeDouyin(url: string) {
|
||||
|
||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined);
|
||||
console.log(chalk.green.bold('✓ 视频作品保存成功'));
|
||||
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');
|
||||
|
||||
164
app/api/search/route.ts
Normal file
164
app/api/search/route.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { json } from '@/lib/json';
|
||||
import { getFileUrl } from '@/lib/minio';
|
||||
import { prisma } from '@/lib/prisma'; // 你的 Prisma 客户端实例
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = (searchParams.get('q') || '').trim();
|
||||
const page = Math.max(1, Number(searchParams.get('page') || 1));
|
||||
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit') || 20)));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json({ results: [], total: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 核心查询:Postgres 中文全文检索(视频转写 + 视频desc + 图文desc)+ 相关度排序 + 摘要高亮
|
||||
// 说明:
|
||||
// - 合并搜索来源: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
|
||||
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
|
||||
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};
|
||||
`;
|
||||
|
||||
// 查询总数(参数化,避免注入)
|
||||
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;
|
||||
`;
|
||||
|
||||
// 分离视频和图文ID
|
||||
const videoIds = rows.filter(r => r.type === 'video').map(r => r.awemeId);
|
||||
const imagePostIds = rows.filter(r => r.type === 'image').map(r => r.awemeId);
|
||||
|
||||
// 批量查询视频元信息
|
||||
const videos = videoIds.length > 0 ? (await prisma.video.findMany({
|
||||
where: { aweme_id: { in: videoIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
desc: true,
|
||||
cover_url: true,
|
||||
video_url: true,
|
||||
duration_ms: true,
|
||||
author: true
|
||||
},
|
||||
})).map(v => (
|
||||
{ ...v, cover_url: getFileUrl(v.cover_url || ''),
|
||||
author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || '') },
|
||||
video_url: getFileUrl(v.video_url || '') })
|
||||
) : [];
|
||||
|
||||
// 批量查询图文元信息
|
||||
const imagePosts = imagePostIds.length > 0 ? (await prisma.imagePost.findMany({
|
||||
where: { aweme_id: { in: imagePostIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
desc: true,
|
||||
author: true,
|
||||
images: {
|
||||
orderBy: { order: 'asc' },
|
||||
take: 1,
|
||||
select: {
|
||||
url: true,
|
||||
width: true,
|
||||
height: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
})).map(ip => ({
|
||||
...ip,
|
||||
author: { ...ip.author, avatar_url: getFileUrl(ip.author.avatar_url || '') },
|
||||
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null,
|
||||
})) : [];
|
||||
|
||||
return json({
|
||||
results: rows.map(r => ({
|
||||
...r,
|
||||
video: r.type === 'video' ? videos.find(v => v.aweme_id === r.awemeId) : undefined,
|
||||
imagePost: r.type === 'image' ? imagePosts.find(ip => ip.aweme_id === r.awemeId) : undefined,
|
||||
})),
|
||||
total: totalRows?.[0]?.count ?? 0,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -4,45 +4,62 @@ import prompt from "./prompt.md";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { downloadFile } from "@/lib/minio";
|
||||
import { extractAudio } from "../media";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_API_BASE_URL,
|
||||
});
|
||||
|
||||
// Zod schema aligned with prompt.md
|
||||
export const SttSchema = z
|
||||
.object({
|
||||
speech_detected: z.boolean(),
|
||||
language: z.string().min(1).nullable(),
|
||||
audio_type: z.string().nullable(),
|
||||
transcript: z.array(z.string()),
|
||||
non_speech_summary: z.string().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SttResult = z.infer<typeof SttSchema>;
|
||||
|
||||
async function transcriptAudio(audio: Buffer | string) {
|
||||
if (typeof audio === "string") {
|
||||
audio = fs.readFileSync(audio);
|
||||
}
|
||||
const base64Audio = Buffer.from(audio).toString("base64");
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "gemini-2.5-flash-lite",
|
||||
const completion = await client.chat.completions.create({
|
||||
model: "gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
{ type: "text", text: prompt },
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: {
|
||||
data: base64Audio,
|
||||
format: "mp3",
|
||||
},
|
||||
input_audio: { data: base64Audio, format: "mp3" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
||||
});
|
||||
|
||||
console.log(response.choices[0].message.content);
|
||||
return response.choices[0].message.content;
|
||||
const data = completion.choices?.[0]?.message
|
||||
if (!data) {
|
||||
throw new Error("No STT result returned from model");
|
||||
}
|
||||
const parsed = SttSchema.safeParse(data?.content).data;
|
||||
if (!parsed) {
|
||||
throw new Error("Failed to parse STT result with zod");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function transcriptAweme(awemeId: string) {
|
||||
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
||||
const aweme = await prisma.video.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
});
|
||||
@ -52,11 +69,34 @@ export async function transcriptAweme(awemeId: string) {
|
||||
const vPath = aweme.video_url
|
||||
const buffer = await downloadFile(vPath);
|
||||
|
||||
const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 96 });
|
||||
const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 128 });
|
||||
|
||||
if (!audioDat || !audioDat.buffer) {
|
||||
throw new Error("Failed to extract audio from video");
|
||||
}
|
||||
|
||||
return await transcriptAudio(audioDat.buffer);
|
||||
// 调用大模型生成转写结果
|
||||
const result = await transcriptAudio(audioDat.buffer);
|
||||
|
||||
// 将转写结果持久化到数据库
|
||||
await prisma.videoTranscript.upsert({
|
||||
where: { videoId: awemeId },
|
||||
update: {
|
||||
speech_detected: result.speech_detected,
|
||||
language: result.language,
|
||||
audio_type: result.audio_type,
|
||||
transcript: result.transcript,
|
||||
non_speech_summary: result.non_speech_summary,
|
||||
},
|
||||
create: {
|
||||
videoId: awemeId,
|
||||
speech_detected: result.speech_detected,
|
||||
language: result.language,
|
||||
audio_type: result.audio_type,
|
||||
transcript: result.transcript,
|
||||
non_speech_summary: result.non_speech_summary,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -1,42 +1,24 @@
|
||||
你将接收一段音频。请完成:
|
||||
A.语音活动检测(VAD)与声源分类;
|
||||
B.条件式处理:
|
||||
- 若包含可辨识的人类发言:** 进行转录 **(保留原语言,不翻译),并尽可能给出说话人分离与时间戳;
|
||||
- 若不包含人类发言:** 不转录 **,仅返回音频类型与简要描述。
|
||||
C.严格输出为下方 JSON,字段不得缺失或额外编造。听不清处用“[听不清]”。
|
||||
你将接收一段音频。请完成:你将接收一段音频。语音活动检测(VAD)与声源分类。
|
||||
|
||||
** 输出 JSON Schema(示例)**
|
||||
**输出 JSON Schema(示例)**
|
||||
|
||||
```json
|
||||
{
|
||||
"speech_detected": true,
|
||||
"language": "zh-CN",
|
||||
"audio_type": null,
|
||||
"background": "music | ambience | none | unknown",
|
||||
"transcript": [
|
||||
{
|
||||
"start": 0.00,
|
||||
"end": 3.42,
|
||||
"text": "大家好,我是……"
|
||||
},
|
||||
{
|
||||
"start": 3.50,
|
||||
"end": 6.10,
|
||||
"text": "欢迎来到今天的节目。"
|
||||
}
|
||||
],
|
||||
"transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
|
||||
"non_speech_summary": null,
|
||||
}
|
||||
```
|
||||
>
|
||||
** 当无发言时返回:**
|
||||
|
||||
**当无发言时返回:**
|
||||
|
||||
```json
|
||||
{
|
||||
"speech_detected": false,
|
||||
"language": null,
|
||||
"audio_type": "music | ambience | animal | mechanical | other",
|
||||
"background": "none",
|
||||
"transcript": [],
|
||||
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
|
||||
}
|
||||
@ -44,8 +26,4 @@ C.严格输出为下方 JSON,字段不得缺失或额外编造。听不清处
|
||||
|
||||
** 规则补充 **
|
||||
|
||||
* 只要存在可理解的人类发言(即便有音乐 / 噪声),就执行转录,并在 `background` 标注“music / ambience”。
|
||||
* 不要将唱词 / 哼唱视为“发言”;若仅有人声演唱且无口语发言,视为 ** 音乐 **。
|
||||
* 不要臆测未听清内容;不要添加与音频无关的信息。
|
||||
* 时间单位统一为秒,保留两位小数。
|
||||
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import type { AwemeData, ImageData, Neighbors, VideoData } from "./types.ts";
|
||||
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";
|
||||
import { ImageNavigationButtons } from "./components/ImageNavigationButtons";
|
||||
import { MediaControls } from "./components/MediaControls";
|
||||
import { NavigationButtons } from "./components/NavigationButtons";
|
||||
import { TranscriptPanel } from "./components/TranscriptPanel";
|
||||
import { VideoPlayer } from "./components/VideoPlayer";
|
||||
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
||||
import { useCommentState } from "./hooks/useCommentState";
|
||||
@ -17,21 +18,24 @@ import { useImageCarousel } from "./hooks/useImageCarousel";
|
||||
import { useNavigation } from "./hooks/useNavigation";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const SEGMENT_MS = 4000;
|
||||
|
||||
interface AwemeDetailClientProps {
|
||||
data: AwemeData;
|
||||
neighbors: Neighbors;
|
||||
transcript: VideoTranscript | null;
|
||||
}
|
||||
|
||||
export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClientProps) {
|
||||
export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const isVideo = data.type === "video";
|
||||
|
||||
// 状态管理
|
||||
const playerState = usePlayerState();
|
||||
const commentState = useCommentState();
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||
|
||||
// 引用
|
||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -284,6 +288,8 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
||||
segmentProgress={imageCarouselState.segProgress}
|
||||
musicUrl={isVideo ? undefined : (data as ImageData).music_url}
|
||||
audioRef={audioRef}
|
||||
hasTranscript={isVideo && !!transcript?.speech_detected}
|
||||
onShowTranscript={() => setTranscriptOpen(true)}
|
||||
onTogglePlay={togglePlay}
|
||||
onSeek={seekTo}
|
||||
onVolumeChange={playerState.setVolume}
|
||||
@ -316,6 +322,13 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
||||
awemeId={data.aweme_id}
|
||||
mounted={commentState.mounted}
|
||||
/>
|
||||
|
||||
{/* 转录面板 */}
|
||||
<TranscriptPanel
|
||||
open={transcriptOpen}
|
||||
onClose={() => setTranscriptOpen(false)}
|
||||
transcript={transcript}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -12,12 +12,14 @@ import {
|
||||
RotateCw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import type { LoopMode, ObjectFit, User } from "../types.ts";
|
||||
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { SegmentedProgressBar } from "./SegmentedProgressBar";
|
||||
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
||||
|
||||
interface MediaControlsProps {
|
||||
isVideo: boolean;
|
||||
@ -40,6 +42,9 @@ interface MediaControlsProps {
|
||||
segmentProgress?: number;
|
||||
musicUrl?: string | null;
|
||||
audioRef?: RefObject<HTMLAudioElement | null>;
|
||||
// 转录相关
|
||||
hasTranscript?: boolean;
|
||||
onShowTranscript?: () => void;
|
||||
// 回调
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (ratio: number) => void;
|
||||
@ -71,6 +76,8 @@ export function MediaControls({
|
||||
segmentProgress = 0,
|
||||
musicUrl,
|
||||
audioRef,
|
||||
hasTranscript = false,
|
||||
onShowTranscript,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
@ -228,6 +235,18 @@ export function MediaControls({
|
||||
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
||||
{isVideo && hasTranscript && onShowTranscript && (
|
||||
<button
|
||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
onClick={onShowTranscript}
|
||||
aria-label="显示转录文本"
|
||||
title="显示转录文本"
|
||||
>
|
||||
<FileText size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 下载 - 中等屏幕以上显示 */}
|
||||
<button
|
||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
@ -238,6 +257,66 @@ export function MediaControls({
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
|
||||
{/* sm屏幕以下会隐藏适配模式,md屏幕以下会隐藏循环、转录、下载、倍速 */}
|
||||
<div className="sm:block md:hidden">
|
||||
<MoreMenu>
|
||||
{/* 小屏幕隐藏的适配模式 */}
|
||||
<div className="sm:hidden">
|
||||
<MoreMenuItem
|
||||
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
||||
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
||||
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中等屏幕以下隐藏的循环模式 */}
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
|
||||
label={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
||||
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中等屏幕以下隐藏的转录按钮 */}
|
||||
{isVideo && hasTranscript && onShowTranscript && (
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={<FileText size={18} />}
|
||||
label="显示转录"
|
||||
onClick={onShowTranscript}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 中等屏幕以下隐藏的下载 */}
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={<Download size={18} />}
|
||||
label={isVideo ? "下载视频" : "下载图片"}
|
||||
onClick={onDownload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 仅在视频模式下显示的倍速(中等屏幕以下) */}
|
||||
{isVideo && (
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={<span className="text-sm font-mono">{rate}x</span>}
|
||||
label="播放速度"
|
||||
onClick={() => {
|
||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||
const i = steps.indexOf(rate);
|
||||
const next = steps[(i + 1) % steps.length];
|
||||
onRateChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</MoreMenu>
|
||||
</div>
|
||||
|
||||
{/* 全屏 - 所有设备都显示 */}
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
@ -246,6 +325,8 @@ export function MediaControls({
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
79
app/aweme/[awemeId]/components/MoreMenu.tsx
Normal file
79
app/aweme/[awemeId]/components/MoreMenu.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { MoreVertical, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface MoreMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MoreMenu({ children }: MoreMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
buttonRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 更多按钮 */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="更多选项"
|
||||
title="更多选项"
|
||||
>
|
||||
{isOpen ? <X size={18} /> : <MoreVertical size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 弹出菜单 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
style={{ minWidth: "200px" }}
|
||||
>
|
||||
<div className="p-2 flex flex-col gap-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreMenuItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function MoreMenuItem({ icon, label, onClick }: MoreMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-white/90 hover:bg-white/10 rounded-lg transition-colors text-left w-full"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
154
app/aweme/[awemeId]/components/TranscriptPanel.tsx
Normal file
154
app/aweme/[awemeId]/components/TranscriptPanel.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { X, Copy, CheckCheck, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { VideoTranscript } from "../types";
|
||||
|
||||
interface TranscriptPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
transcript: VideoTranscript | null;
|
||||
}
|
||||
|
||||
export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleCopy = (text: string, index: number) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyAll = () => {
|
||||
if (!transcript?.transcript) return;
|
||||
const allText = transcript.transcript.join("\n");
|
||||
navigator.clipboard.writeText(allText).then(() => {
|
||||
setCopiedAll(true);
|
||||
setTimeout(() => setCopiedAll(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (!transcript?.speech_detected || !transcript.transcript?.length) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={20} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-8 text-center text-white/60">
|
||||
<p>该视频暂无语音转录内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||
{transcript.language && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
语言: {transcript.language}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors"
|
||||
>
|
||||
{copiedAll ? (
|
||||
<>
|
||||
<CheckCheck size={16} />
|
||||
已复制全部
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={16} />
|
||||
复制全部
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={20} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转录列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{transcript.transcript.map((text, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleCopy(text, index)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-blue-400">
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/90 leading-relaxed break-words">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(text, index)}
|
||||
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
|
||||
aria-label="复制"
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check size={16} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={16} className="text-white/60" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
|
||||
共 {transcript.transcript.length} 段转录内容
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@ import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { Metadata } from "next";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { AwemeData } from "./types";
|
||||
import { AwemeData, VideoTranscript } from "./types";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
|
||||
const id = (await params).awemeId;
|
||||
@ -82,13 +82,16 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
||||
const aweme = post!
|
||||
return {
|
||||
type: "image" as const,
|
||||
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: getFileUrl(img.animated??'default-animated.png'), })),
|
||||
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: getFileUrl(img.animated ?? 'default-animated.png'), })),
|
||||
music_url: getFileUrl(aweme!.music_url || 'default-music.mp3'),
|
||||
};
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const transcript: VideoTranscript | null = isVideo ? await prisma.videoTranscript.findUnique({
|
||||
where: { videoId: id },
|
||||
}) : null;
|
||||
|
||||
// Compute prev/next neighbors by created_at across videos and image posts
|
||||
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at);
|
||||
@ -125,7 +128,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
||||
/>
|
||||
</div>
|
||||
<AwemeDetailClient data={data} neighbors={neighbors} />
|
||||
<AwemeDetailClient data={data} neighbors={neighbors} transcript={transcript} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -37,6 +37,12 @@ export type ImageData = {
|
||||
|
||||
export type AwemeData = VideoData | ImageData;
|
||||
|
||||
export type VideoTranscript = {
|
||||
speech_detected: boolean;
|
||||
language: string | null;
|
||||
transcript: string[];
|
||||
};
|
||||
|
||||
export type Neighbors = {
|
||||
prev: { aweme_id: string } | null;
|
||||
next: { aweme_id: string } | null;
|
||||
|
||||
40
app/components/SearchBox.tsx
Normal file
40
app/components/SearchBox.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export default function SearchBox() {
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative w-full max-w-2xl mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索视频、图文描述或语音内容..."
|
||||
className="w-full bg-white/80 dark:bg-zinc-800/80 border border-zinc-300 dark:border-zinc-700 rounded-full px-5 py-3 pl-12 pr-12 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all shadow-sm"
|
||||
/>
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!query.trim()}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -28,6 +28,15 @@ body {
|
||||
|
||||
/* 滚动条隐藏 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* 搜索结果高亮标记样式 */
|
||||
mark {
|
||||
background-color: rgb(234 179 8 / 0.3); /* yellow-500/30 */
|
||||
color: rgb(253 224 71); /* yellow-300 */
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
.h-screen{
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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";
|
||||
@ -53,9 +54,12 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full px-4 py-8 md:py-12">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
|
||||
作品集
|
||||
</h1>
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const initial = feed.slice(0, 24);
|
||||
|
||||
268
app/search/page.tsx
Normal file
268
app/search/page.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
|
||||
|
||||
// 匹配搜索结果类型(适配后端API)
|
||||
type SearchResultItem = {
|
||||
id: string;
|
||||
awemeId: string;
|
||||
type: 'video' | 'image';
|
||||
rank: number;
|
||||
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
||||
video?: {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
video_url: string | null;
|
||||
duration_ms: number;
|
||||
author: {
|
||||
sec_uid: string;
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
imagePost?: {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
author: {
|
||||
sec_uid: string;
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default function SearchPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryParam = searchParams.get("q") || "";
|
||||
|
||||
const [query, setQuery] = useState(queryParam);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const performSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
} catch (err) {
|
||||
console.error("Search error:", err);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始查询
|
||||
useEffect(() => {
|
||||
if (queryParam) {
|
||||
performSearch(queryParam);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
// 更新 URL
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
performSearch(trimmed);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
router.push("/search");
|
||||
};
|
||||
|
||||
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
|
||||
onClick={() => router.push("/")}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex-1 flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="清空"
|
||||
>
|
||||
<X size={18} className="text-white/60" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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()}
|
||||
>
|
||||
{loading ? "搜索中..." : "搜索"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||
<p className="text-white/60">正在搜索...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/60">
|
||||
<Search size={64} className="mb-4 opacity-20" />
|
||||
<p className="text-xl">未找到相关内容</p>
|
||||
<p className="text-sm mt-2">试试其他关键词</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !searched && !queryParam && (
|
||||
<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>
|
||||
<p className="text-sm mt-2">支持搜索视频描述、图文描述及语音转写内容</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg text-white/80">
|
||||
找到 <span className="text-white font-semibold">{results.length}</span> 个结果
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 单列列表布局 */}
|
||||
<div className="space-y-4">
|
||||
{results.map((item) => {
|
||||
const content = item.type === 'video' ? item.video : item.imagePost;
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.awemeId}
|
||||
className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl overflow-hidden hover:border-white/20 transition-all hover:-translate-y-1"
|
||||
>
|
||||
{/* 主内容区 */}
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* 左侧:封面预览 */}
|
||||
<Link
|
||||
href={`/aweme/${content.aweme_id}`}
|
||||
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"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* 右侧:信息区 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* 类型标签 */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
item.type === 'video'
|
||||
? 'bg-blue-600/20 text-blue-300'
|
||||
: 'bg-purple-600/20 text-purple-300'
|
||||
}`}>
|
||||
{item.type === 'video' ? '视频' : '图文'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
|
||||
<MessageSquare size={12} />
|
||||
匹配度: {(item.rank * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<Link href={`/aweme/${content.aweme_id}`} target="_blank">
|
||||
<p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors">
|
||||
{content.desc || "无描述"}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{/* 作者信息 */}
|
||||
<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"
|
||||
/>
|
||||
<span className="text-xs text-white/60">
|
||||
{content.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 匹配详情:展示后端返回的snippet(已包含<mark>标签) */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
|
||||
<MessageSquare size={14} />
|
||||
匹配内容
|
||||
</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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
bun.lock
3
bun.lock
@ -15,6 +15,7 @@
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "^4.1.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
@ -660,6 +661,8 @@
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
|
||||
10
lib/json.ts
Normal file
10
lib/json.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// lib/json.ts
|
||||
export function json(data: unknown, init?: ResponseInit) {
|
||||
return new Response(
|
||||
JSON.stringify(data, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
||||
{
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', ...(init?.headers || {}) },
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -20,7 +20,8 @@
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Author" (
|
||||
"sec_uid" TEXT NOT NULL,
|
||||
"uid" TEXT,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"signature" TEXT,
|
||||
"avatar_url" TEXT,
|
||||
"follower_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"total_favorited" BIGINT NOT NULL DEFAULT 0,
|
||||
"unique_id" TEXT,
|
||||
"short_id" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Author_pkey" PRIMARY KEY ("sec_uid")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Video" (
|
||||
"aweme_id" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL,
|
||||
"preview_title" TEXT,
|
||||
"duration_ms" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"share_url" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"comment_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"share_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"collect_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Video_pkey" PRIMARY KEY ("aweme_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"avatar_url" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CommentUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"cid" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"videoId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("cid")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Author_uid_key" ON "Author"("uid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_authorId_idx" ON "Video"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_created_at_idx" ON "Video"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommentUser_nickname_avatar_url_key" ON "CommentUser"("nickname", "avatar_url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_videoId_created_at_idx" ON "Comment"("videoId", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -1,9 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `video_url` to the `Video` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "tags" TEXT[],
|
||||
ADD COLUMN "video_url" TEXT NOT NULL;
|
||||
@ -1,50 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImagePost" (
|
||||
"aweme_id" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"share_url" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"comment_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"share_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"collect_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"tags" TEXT[],
|
||||
"music_url" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ImagePost_pkey" PRIMARY KEY ("aweme_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImageFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ImageFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImagePost_authorId_idx" ON "ImagePost"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImagePost_created_at_idx" ON "ImagePost"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImageFile_postId_order_idx" ON "ImageFile"("postId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ImageFile_postId_order_key" ON "ImageFile"("postId", "order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -1,15 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Comment" ADD COLUMN "imagePostId" TEXT,
|
||||
ALTER COLUMN "videoId" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_imagePostId_created_at_idx" ON "Comment"("imagePostId", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "height" INTEGER,
|
||||
ADD COLUMN "width" INTEGER;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "cover_url" TEXT;
|
||||
@ -1,6 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImagePost" ADD COLUMN "raw_json" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Video" ADD COLUMN "fps" INTEGER,
|
||||
ADD COLUMN "raw_json" JSONB;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImageFile" ADD COLUMN "animated" TEXT;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER;
|
||||
@ -1,22 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"commentId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CommentImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommentImage_commentId_order_idx" ON "CommentImage"("commentId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommentImage_commentId_order_key" ON "CommentImage"("commentId", "order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -1,41 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_imagePostId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."CommentImage" DROP CONSTRAINT "CommentImage_commentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."ImageFile" DROP CONSTRAINT "ImageFile_postId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."ImagePost" DROP CONSTRAINT "ImagePost_authorId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "public"."Video" DROP CONSTRAINT "Video_authorId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
200
prisma/migrations/20251025_baseline/migration.sql
Normal file
200
prisma/migrations/20251025_baseline/migration.sql
Normal file
@ -0,0 +1,200 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Author" (
|
||||
"sec_uid" TEXT NOT NULL,
|
||||
"uid" TEXT,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"signature" TEXT,
|
||||
"avatar_url" TEXT,
|
||||
"follower_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"total_favorited" BIGINT NOT NULL DEFAULT 0,
|
||||
"unique_id" TEXT,
|
||||
"short_id" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Author_pkey" PRIMARY KEY ("sec_uid")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Video" (
|
||||
"aweme_id" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL,
|
||||
"preview_title" TEXT,
|
||||
"duration_ms" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"share_url" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"comment_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"share_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"collect_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"fps" INTEGER,
|
||||
"cover_url" TEXT,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"tags" TEXT[],
|
||||
"video_url" TEXT NOT NULL,
|
||||
"raw_json" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Video_pkey" PRIMARY KEY ("aweme_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"avatar_url" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CommentUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"cid" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"videoId" TEXT,
|
||||
"imagePostId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("cid")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"commentId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CommentImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImagePost" (
|
||||
"aweme_id" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL,
|
||||
"share_url" TEXT NOT NULL,
|
||||
"digg_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"comment_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"share_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"collect_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"tags" TEXT[],
|
||||
"music_url" TEXT,
|
||||
"raw_json" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ImagePost_pkey" PRIMARY KEY ("aweme_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImageFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"animated" TEXT,
|
||||
"duration" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ImageFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VideoTranscript" (
|
||||
"id" TEXT NOT NULL,
|
||||
"videoId" TEXT NOT NULL,
|
||||
"speech_detected" BOOLEAN NOT NULL,
|
||||
"language" TEXT,
|
||||
"audio_type" TEXT,
|
||||
"transcript" TEXT[],
|
||||
"non_speech_summary" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VideoTranscript_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Author_uid_key" ON "Author"("uid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_authorId_idx" ON "Video"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Video_created_at_idx" ON "Video"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommentUser_nickname_avatar_url_key" ON "CommentUser"("nickname", "avatar_url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_videoId_created_at_idx" ON "Comment"("videoId", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Comment_imagePostId_created_at_idx" ON "Comment"("imagePostId", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommentImage_commentId_order_idx" ON "CommentImage"("commentId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommentImage_commentId_order_key" ON "CommentImage"("commentId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImagePost_authorId_idx" ON "ImagePost"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImagePost_created_at_idx" ON "ImagePost"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImageFile_postId_order_idx" ON "ImageFile"("postId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ImageFile_postId_order_key" ON "ImageFile"("postId", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VideoTranscript_videoId_key" ON "VideoTranscript"("videoId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VideoTranscript_videoId_idx" ON "VideoTranscript"("videoId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VideoTranscript" ADD CONSTRAINT "VideoTranscript_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@ -8,7 +8,6 @@ datasource db {
|
||||
}
|
||||
|
||||
model Author {
|
||||
// 抖音作者;以 sec_uid 稳定标识
|
||||
sec_uid String @id
|
||||
uid String? @unique
|
||||
nickname String
|
||||
@ -16,13 +15,12 @@ model Author {
|
||||
avatar_url String?
|
||||
follower_count BigInt @default(0)
|
||||
total_favorited BigInt @default(0)
|
||||
unique_id String? // 抖音号
|
||||
unique_id String?
|
||||
short_id String?
|
||||
videos Video[]
|
||||
imagePosts ImagePost[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
imagePosts ImagePost[]
|
||||
videos Video[]
|
||||
}
|
||||
|
||||
model Video {
|
||||
@ -32,35 +30,23 @@ model Video {
|
||||
duration_ms Int
|
||||
created_at DateTime
|
||||
share_url String
|
||||
|
||||
digg_count BigInt @default(0)
|
||||
comment_count BigInt @default(0)
|
||||
share_count BigInt @default(0)
|
||||
collect_count BigInt @default(0)
|
||||
|
||||
// 视频分辨率(用于前端预布局)
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
// 视频帧率
|
||||
fps Int?
|
||||
|
||||
// 视频封面(首帧提取后上传到 MinIO 的外链)
|
||||
cover_url String?
|
||||
|
||||
authorId String
|
||||
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
|
||||
|
||||
comments Comment[]
|
||||
|
||||
tags String[] // 视频标签列表
|
||||
video_url String // 视频文件 URL
|
||||
|
||||
// 保存完整的接口原始 JSON 数据(用于备份和后续分析)
|
||||
raw_json Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tags String[]
|
||||
video_url String
|
||||
height Int?
|
||||
width Int?
|
||||
cover_url String?
|
||||
fps Int?
|
||||
raw_json Json?
|
||||
comments Comment[]
|
||||
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
|
||||
transcript VideoTranscript?
|
||||
|
||||
@@index([authorId])
|
||||
@@index([created_at])
|
||||
@ -70,13 +56,11 @@ model CommentUser {
|
||||
id String @id @default(cuid())
|
||||
nickname String
|
||||
avatar_url String?
|
||||
|
||||
// 以 (nickname, avatar_url) 近似去重;如果你从响应里拿到用户 uid,可以改为以 uid 作为主键
|
||||
@@unique([nickname, avatar_url])
|
||||
|
||||
comments Comment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
|
||||
@@unique([nickname, avatar_url])
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@ -84,92 +68,88 @@ model Comment {
|
||||
text String
|
||||
digg_count BigInt @default(0)
|
||||
created_at DateTime
|
||||
|
||||
// 可关联视频或图文中的一种
|
||||
videoId String?
|
||||
video Video? @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
|
||||
|
||||
imagePostId String?
|
||||
imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id], onDelete: Cascade)
|
||||
|
||||
userId String
|
||||
user CommentUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// 评论下的贴纸/配图(统一按图片存储)
|
||||
images CommentImage[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
imagePostId String?
|
||||
imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id], onDelete: Cascade)
|
||||
user CommentUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
video Video? @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
|
||||
images CommentImage[]
|
||||
|
||||
@@index([videoId, created_at])
|
||||
@@index([imagePostId, created_at])
|
||||
}
|
||||
|
||||
// 评论图片(贴纸和配图统一保存在这里)
|
||||
model CommentImage {
|
||||
id String @id @default(cuid())
|
||||
commentId String
|
||||
comment Comment @relation(fields: [commentId], references: [cid], onDelete: Cascade)
|
||||
|
||||
url String
|
||||
order Int // 在该评论中的顺序(0 开始)
|
||||
order Int
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comment Comment @relation(fields: [commentId], references: [cid], onDelete: Cascade)
|
||||
|
||||
@@index([commentId, order])
|
||||
@@unique([commentId, order])
|
||||
@@index([commentId, order])
|
||||
}
|
||||
|
||||
// 图片作品(图文)
|
||||
model ImagePost {
|
||||
aweme_id String @id
|
||||
desc String
|
||||
created_at DateTime
|
||||
share_url String
|
||||
|
||||
digg_count BigInt @default(0)
|
||||
comment_count BigInt @default(0)
|
||||
share_count BigInt @default(0)
|
||||
collect_count BigInt @default(0)
|
||||
|
||||
authorId String
|
||||
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
|
||||
|
||||
tags String[]
|
||||
music_url String? // 背景音乐(已上传到 MinIO 的外链)
|
||||
|
||||
images ImageFile[]
|
||||
comments Comment[]
|
||||
|
||||
// 保存完整的接口原始 JSON 数据(用于备份和后续分析)
|
||||
raw_json Json?
|
||||
|
||||
music_url String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
raw_json Json?
|
||||
comments Comment[]
|
||||
images ImageFile[]
|
||||
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
|
||||
|
||||
@@index([authorId])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
// 图片作品中的单张图片(已上传到 MinIO 的外链)
|
||||
model ImageFile {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade)
|
||||
url String
|
||||
order Int // 在作品中的顺序(从 0 开始)
|
||||
order Int
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
animated String? // 如果是动图,存储 video 格式的 URL
|
||||
duration Int? // 动图时长(毫秒),仅当 animated 不为 null 时有值
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
animated String?
|
||||
duration Int?
|
||||
post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade)
|
||||
|
||||
@@index([postId, order])
|
||||
@@unique([postId, order])
|
||||
@@index([postId, order])
|
||||
}
|
||||
|
||||
model VideoTranscript {
|
||||
id String @id @default(cuid())
|
||||
videoId String @unique
|
||||
speech_detected Boolean
|
||||
language String?
|
||||
audio_type String?
|
||||
transcript String[]
|
||||
non_speech_summary String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
transcript_zh_tsv Unsupported("tsvector")?
|
||||
video Video @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
|
||||
|
||||
@@index([videoId])
|
||||
@@index([transcript_zh_tsv], map: "video_transcript_zh_idx", type: Gin)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user