优化图文页
This commit is contained in:
parent
674c202264
commit
040273586b
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
}
|
}
|
||||||
|
|||||||
44
.vscode/tasks.json
vendored
44
.vscode/tasks.json
vendored
@ -1,27 +1,19 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "tsc-check",
|
"label": "tsc-check",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": [
|
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"],
|
||||||
"-e",
|
"problemMatcher": ["$tsc"],
|
||||||
"require('typescript').transpile('const x: number = 1;')"
|
"group": "build"
|
||||||
],
|
},
|
||||||
"problemMatcher": [
|
{
|
||||||
"$tsc"
|
"label": "tsc-check (one-off)",
|
||||||
],
|
"type": "shell",
|
||||||
"group": "build"
|
"command": "node",
|
||||||
},
|
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"]
|
||||||
{
|
}
|
||||||
"label": "tsc-check (one-off)",
|
]
|
||||||
"type": "shell",
|
}
|
||||||
"command": "node",
|
|
||||||
"args": [
|
|
||||||
"-e",
|
|
||||||
"require('typescript').transpile('const x: number = 1;')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
|
|||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
This project uses local system font stacks so production builds do not depend on fetching Google Fonts.
|
||||||
|
|
||||||
## Learn More
|
## Learn More
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,17 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import BackButton from '@/app/components/BackButton';
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
|
||||||
export default function SttAdminPage() {
|
export default function SttAdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<h1 className="text-3xl font-bold text-gray-900">STT 管理中心</h1>
|
||||||
STT 管理中心
|
<p className="text-gray-600 mt-2">管理和配置视频语音转写功能</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
管理和配置视频语音转写功能
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-6">
|
<div className="mt-8 grid gap-6">
|
||||||
@ -98,9 +94,7 @@ export default function SttAdminPage() {
|
|||||||
<p className="text-gray-600 mt-1">
|
<p className="text-gray-600 mt-1">
|
||||||
配置转写 API、模型参数和其他高级选项
|
配置转写 API、模型参数和其他高级选项
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 text-gray-400 font-medium">
|
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||||
即将推出
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,9 +124,7 @@ export default function SttAdminPage() {
|
|||||||
<p className="text-gray-600 mt-1">
|
<p className="text-gray-600 mt-1">
|
||||||
查看转写使用情况、成功率、语言分布等统计数据
|
查看转写使用情况、成功率、语言分布等统计数据
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 text-gray-400 font-medium">
|
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||||
即将推出
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import BackButton from '@/app/components/BackButton';
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
|
||||||
type VideoTranscript = {
|
type VideoTranscript = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -34,7 +34,9 @@ export default function SttVideosPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
||||||
const [filter, setFilter] = useState<'all' | 'transcribed' | 'pending'>('all');
|
const [filter, setFilter] = useState<"all" | "transcribed" | "pending">(
|
||||||
|
"all",
|
||||||
|
);
|
||||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
||||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||||
|
|
||||||
@ -45,21 +47,22 @@ export default function SttVideosPage() {
|
|||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await fetch('/api/admin/stt/videos');
|
const response = await fetch("/api/admin/stt/videos");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch videos');
|
throw new Error("Failed to fetch videos");
|
||||||
}
|
}
|
||||||
const data: ApiResponse = await response.json();
|
const data: ApiResponse = await response.json();
|
||||||
setVideos(data.videos);
|
setVideos(data.videos);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 简单的 sleep 工具
|
// 简单的 sleep 工具
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
type PollOptions = {
|
type PollOptions = {
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
@ -69,11 +72,11 @@ export default function SttVideosPage() {
|
|||||||
// 轮询某个视频的转写状态直至完成或超时
|
// 轮询某个视频的转写状态直至完成或超时
|
||||||
const pollVideoUntilTranscribed = async (
|
const pollVideoUntilTranscribed = async (
|
||||||
awemeId: string,
|
awemeId: string,
|
||||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {}
|
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/admin/stt/videos');
|
const resp = await fetch("/api/admin/stt/videos");
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data: ApiResponse = await resp.json();
|
const data: ApiResponse = await resp.json();
|
||||||
setVideos(data.videos);
|
setVideos(data.videos);
|
||||||
@ -91,11 +94,11 @@ export default function SttVideosPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTranscribe = async (awemeId: string) => {
|
const handleTranscribe = async (awemeId: string) => {
|
||||||
setTranscribing(prev => new Set(prev).add(awemeId));
|
setTranscribing((prev) => new Set(prev).add(awemeId));
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
|
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to transcribe video');
|
throw new Error("Failed to transcribe video");
|
||||||
}
|
}
|
||||||
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
|
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
|
||||||
const done = await pollVideoUntilTranscribed(awemeId);
|
const done = await pollVideoUntilTranscribed(awemeId);
|
||||||
@ -104,9 +107,9 @@ export default function SttVideosPage() {
|
|||||||
await fetchVideos();
|
await fetchVideos();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Transcription failed');
|
alert(err instanceof Error ? err.message : "Transcription failed");
|
||||||
} finally {
|
} finally {
|
||||||
setTranscribing(prev => {
|
setTranscribing((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(awemeId);
|
next.delete(awemeId);
|
||||||
return next;
|
return next;
|
||||||
@ -115,13 +118,17 @@ export default function SttVideosPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchTranscribe = async () => {
|
const handleBatchTranscribe = async () => {
|
||||||
const pendingVideos = videos.filter(v => v.transcript === null);
|
const pendingVideos = videos.filter((v) => v.transcript === null);
|
||||||
if (pendingVideos.length === 0) {
|
if (pendingVideos.length === 0) {
|
||||||
alert('没有待转写的视频');
|
alert("没有待转写的视频");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`)) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +139,7 @@ export default function SttVideosPage() {
|
|||||||
for (let i = 0; i < pendingVideos.length; i++) {
|
for (let i = 0; i < pendingVideos.length; i++) {
|
||||||
const video = pendingVideos[i];
|
const video = pendingVideos[i];
|
||||||
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
||||||
setTranscribing(prev => new Set(prev).add(video.aweme_id));
|
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
|
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
|
||||||
@ -141,7 +148,7 @@ export default function SttVideosPage() {
|
|||||||
}
|
}
|
||||||
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
|
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
|
||||||
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
|
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
|
||||||
setTranscribing(prev => {
|
setTranscribing((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(video.aweme_id);
|
next.delete(video.aweme_id);
|
||||||
return next;
|
return next;
|
||||||
@ -160,19 +167,19 @@ export default function SttVideosPage() {
|
|||||||
await fetchVideos();
|
await fetchVideos();
|
||||||
setBatchTranscribing(false);
|
setBatchTranscribing(false);
|
||||||
setBatchProgress({ current: 0, total: 0 });
|
setBatchProgress({ current: 0, total: 0 });
|
||||||
alert('批量转写完成!');
|
alert("批量转写完成!");
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredVideos = videos.filter(v => {
|
const filteredVideos = videos.filter((v) => {
|
||||||
if (filter === 'transcribed') return v.transcript !== null;
|
if (filter === "transcribed") return v.transcript !== null;
|
||||||
if (filter === 'pending') return v.transcript === null;
|
if (filter === "pending") return v.transcript === null;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: videos.length,
|
total: videos.length,
|
||||||
transcribed: videos.filter(v => v.transcript !== null).length,
|
transcribed: videos.filter((v) => v.transcript !== null).length,
|
||||||
pending: videos.filter(v => v.transcript === null).length,
|
pending: videos.filter((v) => v.transcript === null).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -212,7 +219,9 @@ export default function SttVideosPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mt-4">视频转写管理</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mt-4">
|
||||||
|
视频转写管理
|
||||||
|
</h1>
|
||||||
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
|
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -220,15 +229,21 @@ export default function SttVideosPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<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="bg-white rounded-lg shadow p-6">
|
||||||
<div className="text-sm text-gray-600">总视频数</div>
|
<div className="text-sm text-gray-600">总视频数</div>
|
||||||
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div>
|
<div className="text-3xl font-bold text-gray-900 mt-2">
|
||||||
|
{stats.total}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="text-sm text-gray-600">已转写</div>
|
<div className="text-sm text-gray-600">已转写</div>
|
||||||
<div className="text-3xl font-bold text-green-600 mt-2">{stats.transcribed}</div>
|
<div className="text-3xl font-bold text-green-600 mt-2">
|
||||||
|
{stats.transcribed}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="text-sm text-gray-600">待转写</div>
|
<div className="text-sm text-gray-600">待转写</div>
|
||||||
<div className="text-3xl font-bold text-orange-600 mt-2">{stats.pending}</div>
|
<div className="text-3xl font-bold text-orange-600 mt-2">
|
||||||
|
{stats.pending}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -237,31 +252,31 @@ export default function SttVideosPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('all')}
|
onClick={() => setFilter("all")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
filter === 'all'
|
filter === "all"
|
||||||
? 'bg-blue-600 text-white'
|
? "bg-blue-600 text-white"
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
全部 ({stats.total})
|
全部 ({stats.total})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('transcribed')}
|
onClick={() => setFilter("transcribed")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
filter === 'transcribed'
|
filter === "transcribed"
|
||||||
? 'bg-green-600 text-white'
|
? "bg-green-600 text-white"
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
已转写 ({stats.transcribed})
|
已转写 ({stats.transcribed})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('pending')}
|
onClick={() => setFilter("pending")}
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
filter === 'pending'
|
filter === "pending"
|
||||||
? 'bg-orange-600 text-white'
|
? "bg-orange-600 text-white"
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
待转写 ({stats.pending})
|
待转写 ({stats.pending})
|
||||||
@ -284,7 +299,7 @@ export default function SttVideosPage() {
|
|||||||
批量转写中...
|
批量转写中...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'全部转写'
|
"全部转写"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -326,7 +341,7 @@ export default function SttVideosPage() {
|
|||||||
href={`/aweme/${video.aweme_id}`}
|
href={`/aweme/${video.aweme_id}`}
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
|
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
|
||||||
>
|
>
|
||||||
{video.desc || '无描述'}
|
{video.desc || "无描述"}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
@{video.author.nickname}
|
@{video.author.nickname}
|
||||||
@ -357,7 +372,7 @@ export default function SttVideosPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{video.transcript.audio_type || '非语音'}
|
{video.transcript.audio_type || "非语音"}
|
||||||
</span>
|
</span>
|
||||||
{video.transcript.non_speech_summary && (
|
{video.transcript.non_speech_summary && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
@ -386,7 +401,7 @@ export default function SttVideosPage() {
|
|||||||
重新转写...
|
重新转写...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'重新转写'
|
"重新转写"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@ -401,7 +416,7 @@ export default function SttVideosPage() {
|
|||||||
转写中...
|
转写中...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'开始转写'
|
"开始转写"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -413,9 +428,7 @@ export default function SttVideosPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredVideos.length === 0 && (
|
{filteredVideos.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-gray-500">没有找到视频</div>
|
||||||
没有找到视频
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const videos = await prisma.video.findMany({
|
const videos = await prisma.video.findMany({
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: 1000,
|
take: 1000,
|
||||||
include: {
|
include: {
|
||||||
author: {
|
author: {
|
||||||
@ -29,7 +29,7 @@ export async function GET() {
|
|||||||
const formattedVideos = videos.map((v) => ({
|
const formattedVideos = videos.map((v) => ({
|
||||||
aweme_id: v.aweme_id,
|
aweme_id: v.aweme_id,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
cover_url: getFileUrl(v.cover_url ?? ''),
|
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||||
duration_ms: v.duration_ms,
|
duration_ms: v.duration_ms,
|
||||||
author: {
|
author: {
|
||||||
nickname: v.author.nickname,
|
nickname: v.author.nickname,
|
||||||
@ -51,10 +51,10 @@ export async function GET() {
|
|||||||
total: videos.length,
|
total: videos.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch videos:', error);
|
console.error("Failed to fetch videos:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch videos' },
|
{ error: "Failed to fetch videos" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ secUid: string }> }) {
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ secUid: string }> },
|
||||||
|
) {
|
||||||
const secUid = (await params).secUid;
|
const secUid = (await params).secUid;
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limitParam = searchParams.get('limit');
|
const limitParam = searchParams.get("limit");
|
||||||
const beforeParam = searchParams.get('before');
|
const beforeParam = searchParams.get("before");
|
||||||
|
|
||||||
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60
|
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||||
const before = beforeParam ? new Date(beforeParam) : null;
|
const before = beforeParam ? new Date(beforeParam) : null;
|
||||||
|
|
||||||
// fetch chunk from both tables
|
// fetch chunk from both tables
|
||||||
@ -17,20 +20,20 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
|
|||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: {
|
where: {
|
||||||
authorId: secUid,
|
authorId: secUid,
|
||||||
...(before ? { created_at: { lt: before } } : {})
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: { author: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
prisma.imagePost.findMany({
|
||||||
where: {
|
where: {
|
||||||
authorId: secUid,
|
authorId: secUid,
|
||||||
...(before ? { created_at: { lt: before } } : {})
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -41,11 +44,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
@ -55,13 +62,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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))
|
]
|
||||||
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null;
|
const nextCursor =
|
||||||
|
merged.length > 0
|
||||||
|
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
||||||
|
: null;
|
||||||
const payload: FeedResponse = { items: merged, nextCursor };
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,14 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// Find current item timestamp from either table
|
// Find current item timestamp from either table
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
prisma.video.findUnique({
|
||||||
prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
where: { aweme_id: awemeId },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findUnique({
|
||||||
|
where: { aweme_id: awemeId },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const current = video ?? post;
|
const current = video ?? post;
|
||||||
@ -52,25 +58,61 @@ export async function GET(req: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const pickPrev = (() => {
|
const pickPrev = (() => {
|
||||||
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
const cands: {
|
||||||
if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
type: "video" | "image";
|
||||||
if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
aweme_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
}[] = [];
|
||||||
|
if (newerVideo)
|
||||||
|
cands.push({
|
||||||
|
type: "video",
|
||||||
|
aweme_id: newerVideo.aweme_id,
|
||||||
|
created_at: newerVideo.created_at as unknown as Date,
|
||||||
|
});
|
||||||
|
if (newerPost)
|
||||||
|
cands.push({
|
||||||
|
type: "image",
|
||||||
|
aweme_id: newerPost.aweme_id,
|
||||||
|
created_at: newerPost.created_at as unknown as Date,
|
||||||
|
});
|
||||||
if (cands.length === 0) return undefined;
|
if (cands.length === 0) return undefined;
|
||||||
// nearest newer -> minimal created_at
|
// nearest newer -> minimal created_at
|
||||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||||
const h = cands[0];
|
const h = cands[0];
|
||||||
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
return {
|
||||||
|
type: h.type,
|
||||||
|
aweme_id: h.aweme_id,
|
||||||
|
created_at: h.created_at.toISOString(),
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const pickNext = (() => {
|
const pickNext = (() => {
|
||||||
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
const cands: {
|
||||||
if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
type: "video" | "image";
|
||||||
if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
aweme_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
}[] = [];
|
||||||
|
if (olderVideo)
|
||||||
|
cands.push({
|
||||||
|
type: "video",
|
||||||
|
aweme_id: olderVideo.aweme_id,
|
||||||
|
created_at: olderVideo.created_at as unknown as Date,
|
||||||
|
});
|
||||||
|
if (olderPost)
|
||||||
|
cands.push({
|
||||||
|
type: "image",
|
||||||
|
aweme_id: olderPost.aweme_id,
|
||||||
|
created_at: olderPost.created_at as unknown as Date,
|
||||||
|
});
|
||||||
if (cands.length === 0) return undefined;
|
if (cands.length === 0) return undefined;
|
||||||
// nearest older -> maximal created_at among older
|
// nearest older -> maximal created_at among older
|
||||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||||
const h = cands[0];
|
const h = cands[0];
|
||||||
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
return {
|
||||||
|
type: h.type,
|
||||||
|
aweme_id: h.aweme_id,
|
||||||
|
created_at: h.created_at.toISOString(),
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });
|
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ awemeId: string }> }
|
{ params }: { params: Promise<{ awemeId: string }> },
|
||||||
) {
|
) {
|
||||||
const awemeId = (await params).awemeId;
|
const awemeId = (await params).awemeId;
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@ -13,7 +13,8 @@ export async function GET(
|
|||||||
const take = parseInt(searchParams.get("take") || "20", 10);
|
const take = parseInt(searchParams.get("take") || "20", 10);
|
||||||
|
|
||||||
// ranked 模式参数(均为可选,提供合理默认值)
|
// ranked 模式参数(均为可选,提供合理默认值)
|
||||||
const seed = searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
|
const seed =
|
||||||
|
searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
|
||||||
const snapshotIso = searchParams.get("snapshot");
|
const snapshotIso = searchParams.get("snapshot");
|
||||||
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
|
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
|
||||||
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
|
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
|
||||||
@ -21,8 +22,6 @@ export async function GET(
|
|||||||
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
|
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
|
||||||
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
|
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 查找是视频还是图文
|
// 查找是视频还是图文
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
@ -41,9 +40,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建查询条件
|
// 构建查询条件
|
||||||
const where = video
|
const where = video ? { videoId: awemeId } : { imagePostId: awemeId };
|
||||||
? { videoId: awemeId }
|
|
||||||
: { imagePostId: awemeId };
|
|
||||||
|
|
||||||
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
|
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
|
||||||
const total = await prisma.comment.count({ where });
|
const total = await prisma.comment.count({ where });
|
||||||
@ -68,7 +65,9 @@ export async function GET(
|
|||||||
${wJit} * ${jitterExpr}
|
${wJit} * ${jitterExpr}
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
const whereField = (await video) ? Prisma.sql`c."videoId"` : Prisma.sql`c."imagePostId"`;
|
const whereField = (await video)
|
||||||
|
? Prisma.sql`c."videoId"`
|
||||||
|
: Prisma.sql`c."imagePostId"`;
|
||||||
|
|
||||||
const rows: Array<{
|
const rows: Array<{
|
||||||
cid: string;
|
cid: string;
|
||||||
@ -92,22 +91,41 @@ export async function GET(
|
|||||||
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
|
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
|
||||||
OFFSET ${skip}
|
OFFSET ${skip}
|
||||||
LIMIT ${take}
|
LIMIT ${take}
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 批量查询每条评论的配图/贴纸
|
// 批量查询每条评论的配图/贴纸
|
||||||
const cids = rows.map(r => r.cid);
|
const cids = rows.map((r) => r.cid);
|
||||||
const images = cids.length
|
const images = cids.length
|
||||||
? await prisma.commentImage.findMany({
|
? await prisma.commentImage.findMany({
|
||||||
where: { commentId: { in: cids } },
|
where: { commentId: { in: cids } },
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: "asc" },
|
||||||
select: { commentId: true, url: true, width: true, height: true, order: true },
|
select: {
|
||||||
})
|
commentId: true,
|
||||||
|
url: true,
|
||||||
|
width: true,
|
||||||
|
height: true,
|
||||||
|
order: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
const group = new Map<string, { url: string; width?: number | null; height?: number | null; order: number }[]>();
|
const group = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
url: string;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
order: number;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
const arr = group.get(img.commentId) || [];
|
const arr = group.get(img.commentId) || [];
|
||||||
arr.push({ url: img.url, width: img.width, height: img.height, order: img.order });
|
arr.push({
|
||||||
|
url: img.url,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
order: img.order,
|
||||||
|
});
|
||||||
group.set(img.commentId, arr);
|
group.set(img.commentId, arr);
|
||||||
}
|
}
|
||||||
const formattedComments = rows.map((c) => ({
|
const formattedComments = rows.map((c) => ({
|
||||||
@ -117,9 +135,15 @@ export async function GET(
|
|||||||
digg_count: Number(c.digg_count),
|
digg_count: Number(c.digg_count),
|
||||||
user: {
|
user: {
|
||||||
nickname: c.nickname,
|
nickname: c.nickname,
|
||||||
avatar_url: getFileUrl(c.avatar_url || 'default_avatar.png'),
|
avatar_url: getFileUrl(c.avatar_url || "default_avatar.png"),
|
||||||
},
|
},
|
||||||
images: (group.get(c.cid) || []).sort((a, b) => a.order - b.order).map(i => ({ url: getFileUrl(i.url), width: i.width ?? undefined, height: i.height ?? undefined })),
|
images: (group.get(c.cid) || [])
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((i) => ({
|
||||||
|
url: getFileUrl(i.url),
|
||||||
|
width: i.width ?? undefined,
|
||||||
|
height: i.height ?? undefined,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -134,9 +158,6 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取评论失败:", error);
|
console.error("获取评论失败:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "获取评论失败" }, { status: 500 });
|
||||||
{ error: "获取评论失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
|
||||||
// Contract
|
// Contract
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
// Inputs: search params { before?: ISOString, limit?: number }
|
||||||
@ -9,25 +9,25 @@ import { getFileUrl } from '@/lib/minio';
|
|||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limitParam = searchParams.get('limit');
|
const limitParam = searchParams.get("limit");
|
||||||
const beforeParam = searchParams.get('before');
|
const beforeParam = searchParams.get("before");
|
||||||
|
|
||||||
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60
|
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||||
const before = beforeParam ? new Date(beforeParam) : null;
|
const before = beforeParam ? new Date(beforeParam) : null;
|
||||||
|
|
||||||
// fetch chunk from both tables
|
// fetch chunk from both tables
|
||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: before ? { created_at: { lt: before } } : undefined,
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: { author: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
prisma.imagePost.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: before ? { created_at: { lt: before } } : undefined,
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -38,11 +38,15 @@ export async function GET(req: NextRequest) {
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
@ -52,13 +56,21 @@ export async function GET(req: NextRequest) {
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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))
|
]
|
||||||
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null;
|
const nextCursor =
|
||||||
|
merged.length > 0
|
||||||
|
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
||||||
|
: null;
|
||||||
const payload: FeedResponse = { items: merged, nextCursor };
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// index.js
|
// index.js
|
||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from "playwright-extra";
|
||||||
import stealth from 'puppeteer-extra-plugin-stealth'
|
import stealth from "puppeteer-extra-plugin-stealth";
|
||||||
|
|
||||||
chromium.use(stealth());
|
chromium.use(stealth());
|
||||||
|
|
||||||
@ -8,9 +8,9 @@ chromium.use(stealth());
|
|||||||
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
|
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto('https://bot.sannysoft.com/'); // 常用自测页
|
await page.goto("https://bot.sannysoft.com/"); // 常用自测页
|
||||||
console.log('Title:', await page.title());
|
console.log("Title:", await page.title());
|
||||||
await page.screenshot({ path: 'stealth.png', fullPage: true });
|
await page.screenshot({ path: "stealth.png", fullPage: true });
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}, 1000_000);
|
}, 1000_000);
|
||||||
|
|||||||
@ -1,99 +1,98 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { type Browser, type BrowserContext } from 'playwright'
|
import { type Browser, type BrowserContext } from "playwright";
|
||||||
import { chromium } from 'playwright-extra' // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely.
|
import { chromium } from "playwright-extra"; // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely.
|
||||||
import stealth from 'puppeteer-extra-plugin-stealth'
|
import stealth from "puppeteer-extra-plugin-stealth";
|
||||||
chromium.use(stealth());
|
chromium.use(stealth());
|
||||||
|
|
||||||
let contextPromise: Promise<BrowserContext> | null = null
|
let contextPromise: Promise<BrowserContext> | null = null;
|
||||||
let context: BrowserContext | null = null
|
let context: BrowserContext | null = null;
|
||||||
let refCount = 0
|
let refCount = 0;
|
||||||
let idleCloseTimer: NodeJS.Timeout | null = null
|
let idleCloseTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
const USER_DATA_DIR = process.env.USER_DATA_DIR ?? 'chrome-profile/douyin'
|
const USER_DATA_DIR = process.env.USER_DATA_DIR ?? "chrome-profile/douyin";
|
||||||
|
|
||||||
async function launchContext(): Promise<BrowserContext> {
|
async function launchContext(): Promise<BrowserContext> {
|
||||||
const ctx = await chromium.launchPersistentContext(
|
const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, {
|
||||||
USER_DATA_DIR,
|
headless: process.env.CHROMIUM_HEADLESS === "true",
|
||||||
{
|
viewport: {
|
||||||
headless: process.env.CHROMIUM_HEADLESS === 'true',
|
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||||
viewport: {
|
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
|
||||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
},
|
||||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// When the context is closed externally, reset manager state
|
// When the context is closed externally, reset manager state
|
||||||
ctx.on('close', () => {
|
ctx.on("close", () => {
|
||||||
context = null
|
context = null;
|
||||||
contextPromise = null
|
contextPromise = null;
|
||||||
refCount = 0
|
refCount = 0;
|
||||||
if (idleCloseTimer) {
|
if (idleCloseTimer) {
|
||||||
clearTimeout(idleCloseTimer)
|
clearTimeout(idleCloseTimer);
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
return ctx
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
||||||
// Cancel any pending idle close if a new consumer arrives
|
// Cancel any pending idle close if a new consumer arrives
|
||||||
if (idleCloseTimer) {
|
if (idleCloseTimer) {
|
||||||
clearTimeout(idleCloseTimer)
|
clearTimeout(idleCloseTimer);
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context) {
|
if (context) {
|
||||||
refCount += 1
|
refCount += 1;
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contextPromise) {
|
if (!contextPromise) {
|
||||||
contextPromise = launchContext()
|
contextPromise = launchContext();
|
||||||
}
|
}
|
||||||
context = await contextPromise
|
context = await contextPromise;
|
||||||
refCount += 1
|
refCount += 1;
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise<void> {
|
export async function releaseBrowserContext(options?: {
|
||||||
const idleMillis = options?.idleMillis ?? 15_000
|
idleMillis?: number;
|
||||||
refCount = Math.max(0, refCount - 1)
|
}): Promise<void> {
|
||||||
|
const idleMillis = options?.idleMillis ?? 15_000;
|
||||||
|
refCount = Math.max(0, refCount - 1);
|
||||||
|
|
||||||
if (refCount > 0 || !context) return
|
if (refCount > 0 || !context) return;
|
||||||
|
|
||||||
// Delay the close to allow bursty workloads to reuse the context
|
// Delay the close to allow bursty workloads to reuse the context
|
||||||
if (idleCloseTimer) {
|
if (idleCloseTimer) {
|
||||||
clearTimeout(idleCloseTimer)
|
clearTimeout(idleCloseTimer);
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
idleCloseTimer = setTimeout(async () => {
|
idleCloseTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
if (context && refCount === 0) {
|
if (context && refCount === 0) {
|
||||||
await context.close()
|
await context.close();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
context = null
|
context = null;
|
||||||
contextPromise = null
|
contextPromise = null;
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
}, idleMillis)
|
}, idleMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Isolated context support for per-scrape independence ---
|
// --- Isolated context support for per-scrape independence ---
|
||||||
|
|
||||||
const isolatedMap = new WeakMap<BrowserContext, Browser>()
|
const isolatedMap = new WeakMap<BrowserContext, Browser>();
|
||||||
|
|
||||||
async function getSharedStorageState(): Promise<any | undefined> {
|
async function getSharedStorageState(): Promise<any | undefined> {
|
||||||
try {
|
try {
|
||||||
const shared = await acquireBrowserContext()
|
const shared = await acquireBrowserContext();
|
||||||
const state = await shared.storageState()
|
const state = await shared.storageState();
|
||||||
// Do not force-close immediately; keep ref-counting behavior
|
// Do not force-close immediately; keep ref-counting behavior
|
||||||
await releaseBrowserContext()
|
await releaseBrowserContext();
|
||||||
return state
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
// If shared context not available, proceed without storageState
|
// If shared context not available, proceed without storageState
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,37 +102,39 @@ async function getSharedStorageState(): Promise<any | undefined> {
|
|||||||
* so you remain logged-in, but isolates network events, cache and listeners.
|
* so you remain logged-in, but isolates network events, cache and listeners.
|
||||||
*/
|
*/
|
||||||
export async function acquireIsolatedContext(): Promise<BrowserContext> {
|
export async function acquireIsolatedContext(): Promise<BrowserContext> {
|
||||||
const storageState = await getSharedStorageState()
|
const storageState = await getSharedStorageState();
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: process.env.CHROMIUM_HEADLESS === 'true'
|
headless: process.env.CHROMIUM_HEADLESS === "true",
|
||||||
})
|
});
|
||||||
const ctx = await browser.newContext({
|
const ctx = await browser.newContext({
|
||||||
storageState,
|
storageState,
|
||||||
viewport: {
|
viewport: {
|
||||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
|
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
isolatedMap.set(ctx, browser)
|
isolatedMap.set(ctx, browser);
|
||||||
ctx.on('close', () => {
|
ctx.on("close", () => {
|
||||||
const b = isolatedMap.get(ctx)
|
const b = isolatedMap.get(ctx);
|
||||||
if (b) {
|
if (b) {
|
||||||
b.close().catch(() => {})
|
b.close().catch(() => {});
|
||||||
isolatedMap.delete(ctx)
|
isolatedMap.delete(ctx);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
return ctx
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseIsolatedContext(ctx: BrowserContext | null | undefined): Promise<void> {
|
export async function releaseIsolatedContext(
|
||||||
if (!ctx) return
|
ctx: BrowserContext | null | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx) return;
|
||||||
try {
|
try {
|
||||||
await ctx.close()
|
await ctx.close();
|
||||||
} finally {
|
} finally {
|
||||||
const b = isolatedMap.get(ctx)
|
const b = isolatedMap.get(ctx);
|
||||||
if (b) {
|
if (b) {
|
||||||
await b.close().catch(() => {})
|
await b.close().catch(() => {});
|
||||||
isolatedMap.delete(ctx)
|
isolatedMap.delete(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,31 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
// src/scrapeDouyin.ts
|
// src/scrapeDouyin.ts
|
||||||
import { BrowserContext, Page, type Response } from 'playwright';
|
import { BrowserContext, Page, type Response } from "playwright";
|
||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from "playwright-extra";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
import { uploadFile, generateUniqueFileName } from "@/lib/minio";
|
||||||
import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils';
|
import { createCamelCompatibleProxy } from "@/app/api/fetcher/utils";
|
||||||
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary, collectResponsesWithinTime } from '@/app/api/fetcher/network';
|
import {
|
||||||
import { pickBestPlayAddr } from '@/app/api/fetcher/media';
|
waitForFirstResponse,
|
||||||
import { handleImagePost } from '@/app/api/fetcher/uploader';
|
waitForResponseWithTimeout,
|
||||||
import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
|
safeJson,
|
||||||
import chalk from 'chalk';
|
downloadBinary,
|
||||||
import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser';
|
collectResponsesWithinTime,
|
||||||
import { extractFirstFrame } from '@/app/api/media';
|
} from "@/app/api/fetcher/network";
|
||||||
import { transcriptAweme } from '../stt';
|
import { pickBestPlayAddr } from "@/app/api/fetcher/media";
|
||||||
|
import { handleImagePost } from "@/app/api/fetcher/uploader";
|
||||||
|
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 DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
||||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
||||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
const POST_PATH = "/aweme/v1/web/aweme/post/";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滚动页面并收集评论
|
* 滚动页面并收集评论
|
||||||
@ -26,324 +35,444 @@ const POST_PATH = '/aweme/v1/web/aweme/post/'
|
|||||||
* @returns 收集到的所有评论响应
|
* @returns 收集到的所有评论响应
|
||||||
*/
|
*/
|
||||||
async function scrollAndCollectComments(
|
async function scrollAndCollectComments(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
page: Page,
|
page: Page,
|
||||||
durationMs: number = 10_000
|
durationMs: number = 10_000,
|
||||||
): Promise<Response[]> {
|
): Promise<Response[]> {
|
||||||
console.log(chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`));
|
console.log(
|
||||||
|
chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`),
|
||||||
|
);
|
||||||
|
|
||||||
// 启动评论响应收集器
|
// 启动评论响应收集器
|
||||||
const commentResponsesPromise = collectResponsesWithinTime(
|
const commentResponsesPromise = collectResponsesWithinTime(
|
||||||
context,
|
context,
|
||||||
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200 && r.request().frame()?.page() === page,
|
(r: Response) =>
|
||||||
durationMs
|
r.url().includes(COMMENT_PATH) &&
|
||||||
);
|
r.status() === 200 &&
|
||||||
|
r.request().frame()?.page() === page,
|
||||||
|
durationMs,
|
||||||
|
);
|
||||||
|
|
||||||
// 在指定时间内持续滚动页面
|
// 在指定时间内持续滚动页面
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const scrollInterval = 500;
|
const scrollInterval = 500;
|
||||||
let scrollCount = 0;
|
let scrollCount = 0;
|
||||||
const selector = "div[data-e2e='comment-list']";
|
const selector = "div[data-e2e='comment-list']";
|
||||||
|
|
||||||
// 1) 等元素出现并可见
|
// 1) 等元素出现并可见
|
||||||
await page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
|
await page.waitForSelector(selector, { state: "visible", timeout: 5000 });
|
||||||
|
|
||||||
// 2) 确保滚动到可见区域
|
// 2) 确保滚动到可见区域
|
||||||
const list = page.locator(selector);
|
const list = page.locator(selector);
|
||||||
await list.scrollIntoViewIfNeeded();
|
await list.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
// 3) 执行 hover(推荐用 locator 的 hover)
|
// 3) 执行 hover(推荐用 locator 的 hover)
|
||||||
list.hover({ timeout: 5000 }).catch(() => { });
|
list.hover({ timeout: 5000 }).catch(() => {});
|
||||||
while (Date.now() - startTime < durationMs - 500) { // 留 500ms 缓冲
|
while (Date.now() - startTime < durationMs - 500) {
|
||||||
try {
|
// 留 500ms 缓冲
|
||||||
list.hover({ timeout: 2000 }).catch(() => { });
|
try {
|
||||||
// 使用 Playwright 的 mouse.wheel 方法滚动
|
list.hover({ timeout: 2000 }).catch(() => {});
|
||||||
// 每次滚动一大段距离
|
// 使用 Playwright 的 mouse.wheel 方法滚动
|
||||||
// await list.hover();
|
// 每次滚动一大段距离
|
||||||
const scrollAmount = 1500;
|
// await list.hover();
|
||||||
await page.mouse.wheel(0, scrollAmount);
|
const scrollAmount = 1500;
|
||||||
|
await page.mouse.wheel(0, scrollAmount);
|
||||||
|
|
||||||
scrollCount++;
|
scrollCount++;
|
||||||
console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`));
|
console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`));
|
||||||
|
|
||||||
// 等待一段时间,让评论加载
|
// 等待一段时间,让评论加载
|
||||||
await page.waitForTimeout(scrollInterval);
|
await page.waitForTimeout(scrollInterval);
|
||||||
|
} catch (e) {
|
||||||
} catch (e) {
|
console.warn(
|
||||||
console.warn(chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`));
|
chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 等待收集器完成
|
// 等待收集器完成
|
||||||
const commentResponses = await commentResponsesPromise;
|
const commentResponses = await commentResponsesPromise;
|
||||||
console.log(chalk.green(`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`));
|
console.log(
|
||||||
|
chalk.green(
|
||||||
|
`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return commentResponses;
|
return commentResponses;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPostMem(context: BrowserContext, page: Page) {
|
async function readPostMem(context: BrowserContext, page: Page) {
|
||||||
const md = await page.evaluate(() => {
|
const md = await page
|
||||||
// @ts-ignore
|
.evaluate(() => {
|
||||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
const captured = (window as any).__pace_captured__ as Array<unknown[]>;
|
||||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
let data = captured.find(
|
||||||
// return {aweme: { detail: {} } };
|
(item) => typeof item[1] === "string" && item[1].includes(`"awemeId":`),
|
||||||
}).catch(() => null);
|
)?.[1] as string | undefined;
|
||||||
|
if (!data) return null;
|
||||||
|
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ""));
|
||||||
|
// return {aweme: { detail: {} } };
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
// await new Promise((res) => setTimeout(res, 1000000));
|
// await new Promise((res) => setTimeout(res, 1000000));
|
||||||
|
|
||||||
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
||||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
if (!aweme_mem) throw new Error("页面内存数据中未找到作品详情");
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
aweme_mem.author = aweme_mem.authorInfo
|
aweme_mem.author = aweme_mem.authorInfo;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
aweme_mem.statistics = aweme_mem.stats
|
aweme_mem.statistics = aweme_mem.stats;
|
||||||
|
|
||||||
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null;
|
const comments = md.comment
|
||||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment)
|
||||||
|
: null;
|
||||||
|
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||||
|
|
||||||
return { aweme, comments }
|
return { aweme, comments };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class ScrapeError extends Error {
|
export class ScrapeError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public statusCode: number = 500,
|
public statusCode: number = 500,
|
||||||
public code?: string
|
public code?: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ScrapeError';
|
this.name = "ScrapeError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrapeDouyin(url: string) {
|
export async function scrapeDouyin(url: string) {
|
||||||
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...'));
|
console.log(chalk.blue("🚀 启动共享 Chromium 浏览器..."));
|
||||||
let context: BrowserContext | null = await acquireIsolatedContext();
|
let context: BrowserContext | null = await acquireIsolatedContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
// 建一个全局容器存捕获的数据
|
// 建一个全局容器存捕获的数据
|
||||||
(window as any).__pace_captured__ = [];
|
(window as any).__pace_captured__ = [];
|
||||||
|
|
||||||
// 用 Proxy 包装一个数组,拦截 push
|
// 用 Proxy 包装一个数组,拦截 push
|
||||||
const captured = (window as any).__pace_captured__;
|
const captured = (window as any).__pace_captured__;
|
||||||
const proxyArr = new Proxy([] as any[], {
|
const proxyArr = new Proxy([] as any[], {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver) {
|
||||||
if (prop === 'push') {
|
if (prop === "push") {
|
||||||
return (...items: any[]) => {
|
return (...items: any[]) => {
|
||||||
try { captured.push(...items); } catch { }
|
try {
|
||||||
return Array.prototype.push.apply(target, items);
|
captured.push(...items);
|
||||||
};
|
} catch {}
|
||||||
}
|
return Array.prototype.push.apply(target, items);
|
||||||
return Reflect.get(target, prop, receiver);
|
};
|
||||||
},
|
}
|
||||||
set(target, prop, value, receiver) {
|
return Reflect.get(target, prop, receiver);
|
||||||
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
},
|
||||||
if (prop === 'length') return Reflect.set(target, prop, value, receiver);
|
set(target, prop, value, receiver) {
|
||||||
return Reflect.set(target, prop, value, receiver);
|
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
||||||
}
|
if (prop === "length")
|
||||||
});
|
return Reflect.set(target, prop, value, receiver);
|
||||||
|
return Reflect.set(target, prop, value, receiver);
|
||||||
(self as any).__pace_f = proxyArr;
|
},
|
||||||
(window as any).__pace_f = proxyArr;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(self as any).__pace_f = proxyArr;
|
||||||
|
(window as any).__pace_f = proxyArr;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先注册“先到先得”的监听,再导航,避免漏包
|
||||||
|
const firstTypePromise = waitForFirstResponse(
|
||||||
|
context,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "detail",
|
||||||
|
test: (r: Response) =>
|
||||||
|
r.url().includes(DETAIL_PATH) &&
|
||||||
|
r.status() === 200 &&
|
||||||
|
r.request().frame()?.page() === page,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "post",
|
||||||
|
test: (r: Response) =>
|
||||||
|
r.url().includes(POST_PATH) &&
|
||||||
|
r.status() === 200 &&
|
||||||
|
r.request().frame()?.page() === page,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
||||||
|
|
||||||
|
// 查找页面中是否存在 "视频不存在" 的提示
|
||||||
|
const isNotFound = await page
|
||||||
|
.locator("text=视频不存在")
|
||||||
|
.count()
|
||||||
|
.then((count) => count > 0)
|
||||||
|
.catch(() => false);
|
||||||
|
if (isNotFound) {
|
||||||
|
console.error(chalk.red("✗ 视频不存在或已被删除"));
|
||||||
|
throw new ScrapeError("视频不存在或已被删除", 404, "VIDEO_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待作品类型判定
|
||||||
|
const firstType = await firstTypePromise;
|
||||||
|
|
||||||
|
// 尝试从内存读取图文数据(如果是图文作品)
|
||||||
|
let memoryData: {
|
||||||
|
aweme: any;
|
||||||
|
comments: DouyinCommentResponse | null;
|
||||||
|
} | null = null;
|
||||||
try {
|
try {
|
||||||
// 先注册“先到先得”的监听,再导航,避免漏包
|
memoryData = await readPostMem(context, page);
|
||||||
const firstTypePromise = waitForFirstResponse(context, [
|
console.log(chalk.green("✓ 从内存读取图文数据成功"));
|
||||||
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 && r.request().frame()?.page() === page },
|
} catch {
|
||||||
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 && r.request().frame()?.page() === page },
|
// 内存读取失败,稍后通过网络获取
|
||||||
], 10_000);
|
}
|
||||||
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
|
if (!firstType && !memoryData) {
|
||||||
|
console.error(chalk.red("✗ 既无法从内存读取数据,也无法从网络获得数据"));
|
||||||
|
throw new ScrapeError(
|
||||||
|
"无法获取作品数据,可能是网络问题或作品已下架",
|
||||||
|
404,
|
||||||
|
"NO_DATA",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 查找页面中是否存在 "视频不存在" 的提示
|
console.log(
|
||||||
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false);
|
chalk.cyan(
|
||||||
if (isNotFound) {
|
`📡 检测到作品类型: ${chalk.bold(firstType?.key === "post" || memoryData ? "图文" : "视频")}`,
|
||||||
console.error(chalk.red('✗ 视频不存在或已被删除'));
|
),
|
||||||
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND');
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 等待作品类型判定
|
let allComments: DouyinComment[] = [];
|
||||||
const firstType = await firstTypePromise;
|
try {
|
||||||
|
// 开始滚动并收集评论
|
||||||
|
const commentResponses = await scrollAndCollectComments(context, page);
|
||||||
|
|
||||||
// 尝试从内存读取图文数据(如果是图文作品)
|
// 解析所有收集到的评论响应
|
||||||
let memoryData: { aweme: any; comments: DouyinCommentResponse | null } | null = null;
|
for (const commentRes of commentResponses) {
|
||||||
try {
|
try {
|
||||||
memoryData = await readPostMem(context, page);
|
const commentData = await safeJson<DouyinCommentResponse>(commentRes);
|
||||||
console.log(chalk.green('✓ 从内存读取图文数据成功'));
|
if (commentData?.comments?.length) {
|
||||||
} catch {
|
allComments.push(...commentData.comments);
|
||||||
// 内存读取失败,稍后通过网络获取
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!firstType && !memoryData) {
|
// 去重评论(根据 cid)
|
||||||
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据'));
|
const uniqueComments = Array.from(
|
||||||
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA');
|
new Map(allComments.map((c) => [c.cid, c])).values(),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.green(
|
||||||
|
`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果从内存读取到了评论,合并进来作为兜底
|
||||||
|
let comments: DouyinCommentResponse;
|
||||||
|
if (memoryData?.comments?.comments?.length) {
|
||||||
|
console.log(
|
||||||
|
chalk.blue(
|
||||||
|
`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const memComments = memoryData.comments.comments;
|
||||||
|
const mergedMap = new Map(uniqueComments.map((c) => [c.cid, c]));
|
||||||
|
for (const c of memComments) {
|
||||||
|
if (!mergedMap.has(c.cid)) {
|
||||||
|
mergedMap.set(c.cid, c);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
comments = {
|
||||||
|
comments: Array.from(mergedMap.values()),
|
||||||
|
total: mergedMap.size,
|
||||||
|
status_code: 0,
|
||||||
|
};
|
||||||
|
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
|
||||||
|
} else {
|
||||||
|
comments = {
|
||||||
|
comments: uniqueComments,
|
||||||
|
total: uniqueComments.length,
|
||||||
|
status_code: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType?.key === 'post' || memoryData ? '图文' : '视频')}`));
|
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||||
|
// 优先处理内存数据(图文)
|
||||||
|
if (memoryData) {
|
||||||
|
const aweme = memoryData.aweme;
|
||||||
|
const uploads = await handleImagePost(context, aweme);
|
||||||
|
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
||||||
|
console.log(chalk.green.bold("✓ 图文作品保存成功"));
|
||||||
|
return { type: "image", ...saved };
|
||||||
|
} else if (firstType?.key === "post") {
|
||||||
|
// 图文作品(网络)
|
||||||
|
const postJson = await safeJson<DouyinPostListResponse>(
|
||||||
|
firstType.response,
|
||||||
|
);
|
||||||
|
if (!postJson?.aweme_list?.length)
|
||||||
|
throw new ScrapeError("图文作品响应为空", 404, "EMPTY_POST_RESPONSE");
|
||||||
|
|
||||||
let allComments: DouyinComment[] = [];
|
const currentURL = page.url();
|
||||||
try {
|
const target_aweme_id = currentURL.split("/").at(-1);
|
||||||
// 开始滚动并收集评论
|
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
||||||
const commentResponses = await scrollAndCollectComments(context, page);
|
let aweme = awemeList.find(
|
||||||
|
(pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id,
|
||||||
|
);
|
||||||
|
if (!aweme) {
|
||||||
|
throw new ScrapeError(
|
||||||
|
"无法找到目标作品,可能已被删除",
|
||||||
|
404,
|
||||||
|
"POST_NOT_FOUND",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 解析所有收集到的评论响应
|
const uploads = await handleImagePost(context, aweme);
|
||||||
for (const commentRes of commentResponses) {
|
const saved = await saveImagePostToDB(
|
||||||
try {
|
context,
|
||||||
const commentData = await safeJson<DouyinCommentResponse>(commentRes);
|
aweme,
|
||||||
if (commentData?.comments?.length) {
|
comments,
|
||||||
allComments.push(...commentData.comments);
|
uploads,
|
||||||
}
|
postJson,
|
||||||
} catch (e) {
|
); // 传递完整 JSON
|
||||||
console.warn(chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`));
|
console.log(chalk.green.bold("✓ 图文作品保存成功"));
|
||||||
}
|
return { type: "image", ...saved };
|
||||||
}
|
} else if (firstType?.key === "detail") {
|
||||||
} catch (error) {
|
// 视频作品
|
||||||
console.warn(chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`));
|
const detail = (await safeJson<DouyinVideoDetailResponse>(
|
||||||
}
|
firstType.response,
|
||||||
|
))!;
|
||||||
|
|
||||||
|
// 找到比特率最高的 url
|
||||||
|
const bestPlayAddr = pickBestPlayAddr(
|
||||||
|
detail?.aweme_detail?.video.bit_rate,
|
||||||
|
);
|
||||||
|
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||||
|
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
||||||
|
|
||||||
// 去重评论(根据 cid)
|
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
||||||
const uniqueComments = Array.from(
|
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || "N/A")} FPS`));
|
||||||
new Map(allComments.map(c => [c.cid, c])).values()
|
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
||||||
|
console.log(
|
||||||
|
chalk.cyan(
|
||||||
|
`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载视频并上传至 MinIO,获取外链
|
||||||
|
let uploadedUrl: string | undefined;
|
||||||
|
let coverUrl: string | undefined;
|
||||||
|
if (bestVUrl && detail?.aweme_detail) {
|
||||||
|
console.log(chalk.blue("⬇️ 正在下载视频..."));
|
||||||
|
const { buffer, contentType, ext } = await downloadBinary(
|
||||||
|
context,
|
||||||
|
bestVUrl,
|
||||||
|
);
|
||||||
|
const awemeId = detail.aweme_detail.aweme_id;
|
||||||
|
const fileName = generateUniqueFileName(
|
||||||
|
`${awemeId}.${ext}`,
|
||||||
|
"douyin/videos",
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(chalk.green(`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length})`));
|
console.log(chalk.blue("⬆️ 正在上传视频到 MinIO..."));
|
||||||
|
uploadedUrl = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`),
|
||||||
|
);
|
||||||
|
|
||||||
// 如果从内存读取到了评论,合并进来作为兜底
|
// 提取首帧作为封面并上传
|
||||||
let comments: DouyinCommentResponse;
|
try {
|
||||||
if (memoryData?.comments?.comments?.length) {
|
console.log(chalk.blue("🖼️ 正在提取视频封面..."));
|
||||||
console.log(chalk.blue(`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`));
|
const cover = await extractFirstFrame(buffer);
|
||||||
const memComments = memoryData.comments.comments;
|
if (cover) {
|
||||||
const mergedMap = new Map(uniqueComments.map(c => [c.cid, c]));
|
const coverName = generateUniqueFileName(
|
||||||
for (const c of memComments) {
|
`${awemeId}.jpg`,
|
||||||
if (!mergedMap.has(c.cid)) {
|
"douyin/covers",
|
||||||
mergedMap.set(c.cid, c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
comments = {
|
|
||||||
comments: Array.from(mergedMap.values()),
|
|
||||||
total: mergedMap.size,
|
|
||||||
status_code: 0
|
|
||||||
};
|
|
||||||
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
|
|
||||||
} else {
|
|
||||||
comments = {
|
|
||||||
comments: uniqueComments,
|
|
||||||
total: uniqueComments.length,
|
|
||||||
status_code: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
|
||||||
// 优先处理内存数据(图文)
|
|
||||||
if (memoryData) {
|
|
||||||
const aweme = memoryData.aweme;
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
|
||||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
|
||||||
return { type: "image", ...saved };
|
|
||||||
} else if (firstType?.key === 'post') {
|
|
||||||
// 图文作品(网络)
|
|
||||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
|
||||||
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
|
|
||||||
|
|
||||||
const currentURL = page.url();
|
|
||||||
const target_aweme_id = currentURL.split('/').at(-1);
|
|
||||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
|
||||||
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
|
||||||
if (!aweme) {
|
|
||||||
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
|
|
||||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
|
||||||
return { type: "image", ...saved };
|
|
||||||
} else if (firstType?.key === 'detail') {
|
|
||||||
// 视频作品
|
|
||||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
|
||||||
|
|
||||||
// 找到比特率最高的 url
|
|
||||||
const bestPlayAddr = pickBestPlayAddr(
|
|
||||||
detail?.aweme_detail?.video.bit_rate
|
|
||||||
);
|
);
|
||||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
coverUrl = await uploadFile(cover.buffer, coverName, {
|
||||||
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
"Content-Type": cover.contentType,
|
||||||
|
});
|
||||||
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
console.log(
|
||||||
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`));
|
chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`),
|
||||||
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
);
|
||||||
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`));
|
}
|
||||||
}
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
// 下载视频并上传至 MinIO,获取外链
|
chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`),
|
||||||
let uploadedUrl: string | undefined;
|
);
|
||||||
let coverUrl: string | undefined;
|
|
||||||
if (bestVUrl && detail?.aweme_detail) {
|
|
||||||
console.log(chalk.blue('⬇️ 正在下载视频...'));
|
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
|
||||||
const awemeId = detail.aweme_detail.aweme_id;
|
|
||||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
|
||||||
|
|
||||||
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...'));
|
|
||||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
|
||||||
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`));
|
|
||||||
|
|
||||||
// 提取首帧作为封面并上传
|
|
||||||
try {
|
|
||||||
console.log(chalk.blue('🖼️ 正在提取视频封面...'));
|
|
||||||
const cover = await extractFirstFrame(buffer);
|
|
||||||
if (cover) {
|
|
||||||
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
|
||||||
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
|
||||||
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 如果是我们自定义的错误,直接抛出
|
|
||||||
if (error instanceof ScrapeError) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理其他类型的错误
|
const saved = await saveToDB(
|
||||||
const errMsg = (error as Error)?.message || String(error);
|
context,
|
||||||
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
detail,
|
||||||
|
comments,
|
||||||
// 根据错误类型返回不同的状态码
|
uploadedUrl,
|
||||||
if (errMsg.includes('timeout') || errMsg.includes('超时')) {
|
bestPlayAddr?.width,
|
||||||
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT');
|
bestPlayAddr?.height,
|
||||||
}
|
coverUrl,
|
||||||
if (errMsg.includes('页面内存数据中未找到作品详情')) {
|
fps ?? undefined,
|
||||||
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED');
|
);
|
||||||
}
|
console.log(chalk.green.bold("✓ 视频作品保存成功"));
|
||||||
if (errMsg.includes('net::')) {
|
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
|
||||||
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR');
|
return { type: "video", ...saved };
|
||||||
}
|
} else {
|
||||||
|
throw new ScrapeError(
|
||||||
// 默认服务器错误
|
"无法判定作品类型,接口响应异常",
|
||||||
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR');
|
500,
|
||||||
} finally {
|
"UNKNOWN_TYPE",
|
||||||
console.log(chalk.gray('🧹 清理资源...'));
|
);
|
||||||
try { await page.close({ runBeforeUnload: true }); } catch { }
|
}
|
||||||
// 关闭本次任务的隔离上下文与浏览器
|
} catch (error) {
|
||||||
await releaseIsolatedContext(context);
|
// 如果是我们自定义的错误,直接抛出
|
||||||
await prisma.$disconnect();
|
if (error instanceof ScrapeError) {
|
||||||
console.log(chalk.gray('✓ 资源清理完成'));
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// 处理其他类型的错误
|
||||||
|
const errMsg = (error as Error)?.message || String(error);
|
||||||
|
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
||||||
|
|
||||||
|
// 根据错误类型返回不同的状态码
|
||||||
|
if (errMsg.includes("timeout") || errMsg.includes("超时")) {
|
||||||
|
throw new ScrapeError("请求超时,请稍后重试", 408, "TIMEOUT");
|
||||||
|
}
|
||||||
|
if (errMsg.includes("页面内存数据中未找到作品详情")) {
|
||||||
|
throw new ScrapeError("作品数据加载失败", 404, "DATA_NOT_LOADED");
|
||||||
|
}
|
||||||
|
if (errMsg.includes("net::")) {
|
||||||
|
throw new ScrapeError("网络连接失败", 503, "NETWORK_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认服务器错误
|
||||||
|
throw new ScrapeError(
|
||||||
|
errMsg || "爬取过程中发生未知错误",
|
||||||
|
500,
|
||||||
|
"UNKNOWN_ERROR",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
console.log(chalk.gray("🧹 清理资源..."));
|
||||||
|
try {
|
||||||
|
await page.close({ runBeforeUnload: true });
|
||||||
|
} catch {}
|
||||||
|
// 关闭本次任务的隔离上下文与浏览器
|
||||||
|
await releaseIsolatedContext(context);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log(chalk.gray("✓ 资源清理完成"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { execFile } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
||||||
if (!variants?.length) return null;
|
if (!variants?.length) return null;
|
||||||
|
|
||||||
const best = variants.reduce((best, cur) => {
|
const best = variants.reduce((best, cur) => {
|
||||||
const b1 = best?.bit_rate ?? -1;
|
const b1 = best?.bit_rate ?? -1;
|
||||||
const b2 = cur?.bit_rate ?? -1;
|
const b2 = cur?.bit_rate ?? -1;
|
||||||
return b2 > b1 ? cur : best;
|
return b2 > b1 ? cur : best;
|
||||||
});
|
});
|
||||||
|
|
||||||
return best?.play_addr ?? null;
|
return best?.play_addr ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import type { BrowserContext, Response } from 'playwright';
|
import type { BrowserContext, Response } from "playwright";
|
||||||
|
|
||||||
export async function safeJson<T>(res: Response): Promise<T | null> {
|
export async function safeJson<T>(res: Response): Promise<T | null> {
|
||||||
const ctype = res.headers()['content-type'] || '';
|
const ctype = res.headers()["content-type"] || "";
|
||||||
if (ctype.includes('application/json')) {
|
if (ctype.includes("application/json")) {
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
try {
|
try {
|
||||||
return JSON.parse(t) as T;
|
return JSON.parse(t) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,30 +21,31 @@ export async function safeJson<T>(res: Response): Promise<T | null> {
|
|||||||
* - referrer 使用链接本身
|
* - referrer 使用链接本身
|
||||||
*/
|
*/
|
||||||
export async function downloadBinary(
|
export async function downloadBinary(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||||
console.log('下载:', url);
|
console.log("下载:", url);
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
referer: 'https://www.douyin.com/',
|
referer: "https://www.douyin.com/",
|
||||||
} as Record<string, string>;
|
} as Record<string, string>;
|
||||||
|
|
||||||
const res = await context.request.get(url, {
|
const res = await context.request.get(url, {
|
||||||
headers,
|
headers,
|
||||||
maxRedirects: 3,
|
maxRedirects: 3,
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
failOnStatusCode: true,
|
failOnStatusCode: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
|
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await res.body();
|
const buffer = await res.body();
|
||||||
const contentType = res.headers()['content-type'] || 'application/octet-stream';
|
const contentType =
|
||||||
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin';
|
res.headers()["content-type"] || "application/octet-stream";
|
||||||
return { buffer, contentType, ext };
|
const ext = (contentType.split("/")[1] || "bin").split(";")[0] || "bin";
|
||||||
|
return { buffer, contentType, ext };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,46 +53,46 @@ export async function downloadBinary(
|
|||||||
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
||||||
*/
|
*/
|
||||||
export function waitForFirstResponse(
|
export function waitForFirstResponse(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
candidates: { key: string; test: (r: Response) => boolean }[],
|
candidates: { key: string; test: (r: Response) => boolean }[],
|
||||||
timeoutMs = 20_000
|
timeoutMs = 20_000,
|
||||||
): Promise<{ key: string; response: Response } | null> {
|
): Promise<{ key: string; response: Response } | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
let timer: NodeJS.Timeout | undefined;
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
const handler = (res: Response) => {
|
const handler = (res: Response) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
try {
|
try {
|
||||||
if (c.test(res)) {
|
if (c.test(res)) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve({ key: c.key, response: res });
|
resolve({ key: c.key, response: res });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore predicate errors
|
// ignore predicate errors
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
context.off('response', handler);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
context.on('response', handler);
|
|
||||||
if (timeoutMs > 0) {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
cleanup();
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}, timeoutMs);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
context.off("response", handler);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
context.on("response", handler);
|
||||||
|
if (timeoutMs > 0) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -99,76 +100,76 @@ export function waitForFirstResponse(
|
|||||||
* 用于评论等需要滚动加载的数据
|
* 用于评论等需要滚动加载的数据
|
||||||
*/
|
*/
|
||||||
export function collectResponsesWithinTime(
|
export function collectResponsesWithinTime(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
predicate: (r: Response) => boolean,
|
predicate: (r: Response) => boolean,
|
||||||
durationMs: number
|
durationMs: number,
|
||||||
): Promise<Response[]> {
|
): Promise<Response[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const collected: Response[] = [];
|
const collected: Response[] = [];
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
let timer: NodeJS.Timeout | undefined;
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
const handler = (res: Response) => {
|
const handler = (res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (predicate(res)) {
|
if (predicate(res)) {
|
||||||
// 使用 URL 去重,避免重复收集同一个请求
|
// 使用 URL 去重,避免重复收集同一个请求
|
||||||
const url = res.url();
|
const url = res.url();
|
||||||
if (!seenUrls.has(url)) {
|
if (!seenUrls.has(url)) {
|
||||||
seenUrls.add(url);
|
seenUrls.add(url);
|
||||||
collected.push(res);
|
collected.push(res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore predicate errors
|
// ignore predicate errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
context.off('response', handler);
|
context.off("response", handler);
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
|
|
||||||
context.on('response', handler);
|
context.on("response", handler);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve(collected);
|
resolve(collected);
|
||||||
}, durationMs);
|
}, durationMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 等待符合条件的单个 Response,带短超时;用于评论等"可有可无"的数据。
|
* 等待符合条件的单个 Response,带短超时;用于评论等"可有可无"的数据。
|
||||||
*/
|
*/
|
||||||
export function waitForResponseWithTimeout(
|
export function waitForResponseWithTimeout(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
predicate: (r: Response) => boolean,
|
predicate: (r: Response) => boolean,
|
||||||
timeoutMs = 5_000
|
timeoutMs = 5_000,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return new Promise<Response>((resolve, reject) => {
|
return new Promise<Response>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | undefined;
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
const handler = (res: Response) => {
|
const handler = (res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (predicate(res)) {
|
if (predicate(res)) {
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve(res);
|
resolve(res);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore predicate errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
context.off('response', handler);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
context.on('response', handler);
|
|
||||||
if (timeoutMs > 0) {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error('timeout'));
|
|
||||||
}, timeoutMs);
|
|
||||||
}
|
}
|
||||||
});
|
} catch {
|
||||||
|
// ignore predicate errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
context.off("response", handler);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
context.on("response", handler);
|
||||||
|
if (timeoutMs > 0) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("timeout"));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,359 +1,427 @@
|
|||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from "playwright";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import { uploadAvatarFromUrl, uploadImageFromUrl } from './uploader';
|
import { uploadAvatarFromUrl, uploadImageFromUrl } from "./uploader";
|
||||||
import { firstUrl } from './utils';
|
import { firstUrl } from "./utils";
|
||||||
|
|
||||||
export async function saveToDB(
|
export async function saveToDB(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
detailResp: DouyinVideoDetailResponse,
|
detailResp: DouyinVideoDetailResponse,
|
||||||
commentResp: DouyinCommentResponse,
|
commentResp: DouyinCommentResponse,
|
||||||
videoUrl?: string,
|
videoUrl?: string,
|
||||||
width?: number,
|
width?: number,
|
||||||
height?: number,
|
height?: number,
|
||||||
coverUrl?: string,
|
coverUrl?: string,
|
||||||
fps?: number
|
fps?: number,
|
||||||
) {
|
) {
|
||||||
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
if (!detailResp?.aweme_detail) throw new Error("视频详情为空");
|
||||||
const d = detailResp.aweme_detail;
|
const d = detailResp.aweme_detail;
|
||||||
|
|
||||||
// 1) Upsert Author
|
// 1) Upsert Author
|
||||||
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
||||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`);
|
const authorAvatarUploaded = await uploadAvatarFromUrl(
|
||||||
const author = await prisma.author.upsert({
|
context,
|
||||||
where: { sec_uid: d.author.sec_uid },
|
authorAvatarSrc,
|
||||||
create: {
|
`authors/${d.author.sec_uid}`,
|
||||||
sec_uid: d.author.sec_uid,
|
);
|
||||||
uid: d.author.uid,
|
const author = await prisma.author.upsert({
|
||||||
nickname: d.author.nickname,
|
where: { sec_uid: d.author.sec_uid },
|
||||||
signature: d.author.signature ?? null,
|
create: {
|
||||||
avatar_url: authorAvatarUploaded ?? null,
|
sec_uid: d.author.sec_uid,
|
||||||
follower_count: BigInt(d.author.follower_count || 0),
|
uid: d.author.uid,
|
||||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
nickname: d.author.nickname,
|
||||||
unique_id: d.author.unique_id ?? null,
|
signature: d.author.signature ?? null,
|
||||||
short_id: d.author.short_id ?? null,
|
avatar_url: authorAvatarUploaded ?? null,
|
||||||
},
|
follower_count: BigInt(d.author.follower_count || 0),
|
||||||
update: {
|
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||||
uid: d.author.uid,
|
unique_id: d.author.unique_id ?? null,
|
||||||
nickname: d.author.nickname,
|
short_id: d.author.short_id ?? null,
|
||||||
signature: d.author.signature ?? null,
|
},
|
||||||
avatar_url: authorAvatarUploaded ?? null,
|
update: {
|
||||||
follower_count: BigInt(d.author.follower_count || 0),
|
uid: d.author.uid,
|
||||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
nickname: d.author.nickname,
|
||||||
unique_id: d.author.unique_id ?? null,
|
signature: d.author.signature ?? null,
|
||||||
short_id: d.author.short_id ?? null,
|
avatar_url: authorAvatarUploaded ?? null,
|
||||||
|
follower_count: BigInt(d.author.follower_count || 0),
|
||||||
|
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||||
|
unique_id: d.author.unique_id ?? null,
|
||||||
|
short_id: d.author.short_id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Upsert Video
|
||||||
|
const video = await prisma.video.upsert({
|
||||||
|
where: { aweme_id: d.aweme_id },
|
||||||
|
create: {
|
||||||
|
aweme_id: d.aweme_id,
|
||||||
|
desc: d.desc,
|
||||||
|
preview_title: d.preview_title ?? null,
|
||||||
|
duration_ms: d.duration,
|
||||||
|
created_at: new Date((d.create_time || 0) * 1000),
|
||||||
|
share_url: d.share_url,
|
||||||
|
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||||
|
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||||
|
share_count: BigInt(d.statistics?.share_count || 0),
|
||||||
|
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||||
|
authorId: author.sec_uid,
|
||||||
|
tags: d.tags?.map((t) => t.tag_name) ?? [],
|
||||||
|
video_url: videoUrl ?? "",
|
||||||
|
width: width ?? null,
|
||||||
|
height: height ?? null,
|
||||||
|
cover_url: coverUrl ?? null,
|
||||||
|
fps: fps ?? null,
|
||||||
|
raw_json: detailResp as any, // 保存完整接口 JSON
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
desc: d.desc,
|
||||||
|
preview_title: d.preview_title ?? null,
|
||||||
|
duration_ms: d.duration,
|
||||||
|
created_at: new Date((d.create_time || 0) * 1000),
|
||||||
|
share_url: d.share_url,
|
||||||
|
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||||
|
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||||
|
share_count: BigInt(d.statistics?.share_count || 0),
|
||||||
|
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||||
|
authorId: author.sec_uid,
|
||||||
|
...(videoUrl ? { video_url: videoUrl } : {}),
|
||||||
|
...(width ? { width } : {}),
|
||||||
|
...(height ? { height } : {}),
|
||||||
|
...(coverUrl ? { cover_url: coverUrl } : {}),
|
||||||
|
...(fps ? { fps } : {}),
|
||||||
|
raw_json: detailResp as any, // 更新完整接口 JSON
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Upsert Comments + CommentUser
|
||||||
|
const comments = commentResp?.comments ?? [];
|
||||||
|
for (const c of comments) {
|
||||||
|
const origAvatar: string | null =
|
||||||
|
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||||
|
const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
|
||||||
|
const uploadedAvatar = await uploadAvatarFromUrl(
|
||||||
|
context,
|
||||||
|
origAvatar ?? undefined,
|
||||||
|
nameHint,
|
||||||
|
);
|
||||||
|
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||||
|
const finalAvatarKey = finalAvatar ?? "";
|
||||||
|
const cu = await prisma.commentUser.upsert({
|
||||||
|
where: {
|
||||||
|
nickname_avatar_url: {
|
||||||
|
nickname: c.user?.nickname || "未知用户",
|
||||||
|
avatar_url: finalAvatarKey,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
nickname: c.user?.nickname || "未知用户",
|
||||||
|
avatar_url: finalAvatar ?? null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
avatar_url: finalAvatar ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2) Upsert Video
|
const savedComment = await prisma.comment.upsert({
|
||||||
const video = await prisma.video.upsert({
|
where: { cid: c.cid },
|
||||||
where: { aweme_id: d.aweme_id },
|
create: {
|
||||||
create: {
|
cid: c.cid,
|
||||||
aweme_id: d.aweme_id,
|
text: c.text,
|
||||||
desc: d.desc,
|
digg_count: BigInt(c.digg_count || 0),
|
||||||
preview_title: d.preview_title ?? null,
|
created_at: new Date((c.create_time || 0) * 1000),
|
||||||
duration_ms: d.duration,
|
videoId: video.aweme_id,
|
||||||
created_at: new Date((d.create_time || 0) * 1000),
|
userId: cu.id,
|
||||||
share_url: d.share_url,
|
},
|
||||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
update: {
|
||||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
text: c.text,
|
||||||
share_count: BigInt(d.statistics?.share_count || 0),
|
digg_count: BigInt(c.digg_count || 0),
|
||||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
created_at: new Date((c.create_time || 0) * 1000),
|
||||||
authorId: author.sec_uid,
|
videoId: video.aweme_id,
|
||||||
tags: (d.tags?.map(t => t.tag_name) ?? []),
|
userId: cu.id,
|
||||||
video_url: videoUrl ?? '',
|
},
|
||||||
width: width ?? null,
|
|
||||||
height: height ?? null,
|
|
||||||
cover_url: coverUrl ?? null,
|
|
||||||
fps: fps ?? null,
|
|
||||||
raw_json: detailResp as any, // 保存完整接口 JSON
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
desc: d.desc,
|
|
||||||
preview_title: d.preview_title ?? null,
|
|
||||||
duration_ms: d.duration,
|
|
||||||
created_at: new Date((d.create_time || 0) * 1000),
|
|
||||||
share_url: d.share_url,
|
|
||||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
|
||||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
|
||||||
share_count: BigInt(d.statistics?.share_count || 0),
|
|
||||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
|
||||||
authorId: author.sec_uid,
|
|
||||||
...(videoUrl ? { video_url: videoUrl } : {}),
|
|
||||||
...(width ? { width } : {}),
|
|
||||||
...(height ? { height } : {}),
|
|
||||||
...(coverUrl ? { cover_url: coverUrl } : {}),
|
|
||||||
...(fps ? { fps } : {}),
|
|
||||||
raw_json: detailResp as any, // 更新完整接口 JSON
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3) Upsert Comments + CommentUser
|
// 处理评论贴纸/配图上传与入库
|
||||||
const comments = commentResp?.comments ?? [];
|
try {
|
||||||
for (const c of comments) {
|
const sources: {
|
||||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
url?: string | null;
|
||||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
width?: number;
|
||||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
height?: number;
|
||||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
}[] = [];
|
||||||
const finalAvatarKey = finalAvatar ?? '';
|
// 贴纸(当作第一张)
|
||||||
const cu = await prisma.commentUser.upsert({
|
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
||||||
where: {
|
if (stickerUrl) {
|
||||||
nickname_avatar_url: {
|
sources.push({
|
||||||
nickname: c.user?.nickname || '未知用户',
|
url: stickerUrl,
|
||||||
avatar_url: finalAvatarKey,
|
width: c.sticker?.animate_url?.width,
|
||||||
},
|
height: c.sticker?.animate_url?.height,
|
||||||
},
|
|
||||||
create: {
|
|
||||||
nickname: c.user?.nickname || '未知用户',
|
|
||||||
avatar_url: finalAvatar ?? null,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
avatar_url: finalAvatar ?? null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
// 配图列表
|
||||||
|
const imgs = c.image_list || [];
|
||||||
|
for (const it of imgs) {
|
||||||
|
const u = firstUrl(it?.origin_url?.url_list);
|
||||||
|
if (u)
|
||||||
|
sources.push({
|
||||||
|
url: u,
|
||||||
|
width: it?.origin_url?.width as any,
|
||||||
|
height: it?.origin_url?.height as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const savedComment = await prisma.comment.upsert({
|
for (let i = 0; i < sources.length; i++) {
|
||||||
where: { cid: c.cid },
|
const s = sources[i];
|
||||||
create: {
|
const uploaded = await uploadImageFromUrl(
|
||||||
cid: c.cid,
|
context,
|
||||||
text: c.text,
|
s.url ?? undefined,
|
||||||
digg_count: BigInt(c.digg_count || 0),
|
`comments/${c.cid}/${i}`,
|
||||||
created_at: new Date((c.create_time || 0) * 1000),
|
);
|
||||||
videoId: video.aweme_id,
|
if (!uploaded) continue;
|
||||||
userId: cu.id,
|
await prisma.commentImage.upsert({
|
||||||
},
|
where: { commentId_order: { commentId: savedComment.cid, order: i } },
|
||||||
update: {
|
create: {
|
||||||
text: c.text,
|
commentId: savedComment.cid,
|
||||||
digg_count: BigInt(c.digg_count || 0),
|
order: i,
|
||||||
created_at: new Date((c.create_time || 0) * 1000),
|
url: uploaded,
|
||||||
videoId: video.aweme_id,
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
userId: cu.id,
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
},
|
},
|
||||||
|
update: {
|
||||||
|
url: uploaded,
|
||||||
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// 处理评论贴纸/配图上传与入库
|
} catch (e) {
|
||||||
try {
|
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
|
||||||
const sources: { url?: string | null; width?: number; height?: number }[] = [];
|
|
||||||
// 贴纸(当作第一张)
|
|
||||||
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
|
||||||
if (stickerUrl) {
|
|
||||||
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
|
|
||||||
}
|
|
||||||
// 配图列表
|
|
||||||
const imgs = c.image_list || [];
|
|
||||||
for (const it of imgs) {
|
|
||||||
const u = firstUrl(it?.origin_url?.url_list);
|
|
||||||
if (u) sources.push({ url: u, width: it?.origin_url?.width as any, height: it?.origin_url?.height as any });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < sources.length; i++) {
|
|
||||||
const s = sources[i];
|
|
||||||
const uploaded = await uploadImageFromUrl(
|
|
||||||
context,
|
|
||||||
s.url ?? undefined,
|
|
||||||
`comments/${c.cid}/${i}`,
|
|
||||||
);
|
|
||||||
if (!uploaded) continue;
|
|
||||||
await prisma.commentImage.upsert({
|
|
||||||
where: { commentId_order: { commentId: savedComment.cid, order: i } },
|
|
||||||
create: {
|
|
||||||
commentId: savedComment.cid,
|
|
||||||
order: i,
|
|
||||||
url: uploaded,
|
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
url: uploaded,
|
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length };
|
return {
|
||||||
|
aweme_id: video.aweme_id,
|
||||||
|
author_sec_uid: author.sec_uid,
|
||||||
|
comment_count: comments.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImagePostToDB(
|
export async function saveImagePostToDB(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
aweme: DouyinImageAweme,
|
aweme: DouyinImageAweme,
|
||||||
commentResp: DouyinCommentResponse,
|
commentResp: DouyinCommentResponse,
|
||||||
uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string },
|
uploads: {
|
||||||
rawJson?: any
|
images: {
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
video?: string;
|
||||||
|
duration?: number;
|
||||||
|
}[];
|
||||||
|
musicUrl?: string;
|
||||||
|
},
|
||||||
|
rawJson?: any,
|
||||||
) {
|
) {
|
||||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
if (!aweme?.author?.sec_uid) throw new Error("作者 sec_uid 缺失");
|
||||||
|
|
||||||
// Upsert Author(与视频一致)
|
// Upsert Author(与视频一致)
|
||||||
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
||||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`);
|
const authorAvatarUploaded = await uploadAvatarFromUrl(
|
||||||
const author = await prisma.author.upsert({
|
context,
|
||||||
where: { sec_uid: aweme.author.sec_uid },
|
authorAvatarSrc,
|
||||||
create: {
|
`authors/${aweme.author.sec_uid}`,
|
||||||
sec_uid: aweme.author.sec_uid,
|
);
|
||||||
uid: aweme.author.uid,
|
const author = await prisma.author.upsert({
|
||||||
nickname: aweme.author.nickname,
|
where: { sec_uid: aweme.author.sec_uid },
|
||||||
signature: aweme.author.signature ?? null,
|
create: {
|
||||||
avatar_url: authorAvatarUploaded ?? null,
|
sec_uid: aweme.author.sec_uid,
|
||||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
uid: aweme.author.uid,
|
||||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
nickname: aweme.author.nickname,
|
||||||
unique_id: (aweme.author as any).unique_id ?? null,
|
signature: aweme.author.signature ?? null,
|
||||||
short_id: (aweme.author as any).short_id ?? null,
|
avatar_url: authorAvatarUploaded ?? null,
|
||||||
},
|
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||||
update: {
|
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||||
uid: aweme.author.uid,
|
unique_id: (aweme.author as any).unique_id ?? null,
|
||||||
nickname: aweme.author.nickname,
|
short_id: (aweme.author as any).short_id ?? null,
|
||||||
signature: aweme.author.signature ?? null,
|
},
|
||||||
avatar_url: authorAvatarUploaded ?? null,
|
update: {
|
||||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
uid: aweme.author.uid,
|
||||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
nickname: aweme.author.nickname,
|
||||||
unique_id: (aweme.author as any).unique_id ?? null,
|
signature: aweme.author.signature ?? null,
|
||||||
short_id: (aweme.author as any).short_id ?? null,
|
avatar_url: authorAvatarUploaded ?? null,
|
||||||
|
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||||
|
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||||
|
unique_id: (aweme.author as any).unique_id ?? null,
|
||||||
|
short_id: (aweme.author as any).short_id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert ImagePost
|
||||||
|
const imagePost = await prisma.imagePost.upsert({
|
||||||
|
where: { aweme_id: aweme.aweme_id },
|
||||||
|
create: {
|
||||||
|
aweme_id: aweme.aweme_id,
|
||||||
|
desc: aweme.desc,
|
||||||
|
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||||
|
share_url: aweme.share_url || "",
|
||||||
|
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||||
|
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||||
|
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||||
|
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||||
|
authorId: author.sec_uid,
|
||||||
|
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
|
||||||
|
music_url: uploads.musicUrl ?? null,
|
||||||
|
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
desc: aweme.desc,
|
||||||
|
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||||
|
share_url: aweme.share_url,
|
||||||
|
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||||
|
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||||
|
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||||
|
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||||
|
authorId: author.sec_uid,
|
||||||
|
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
|
||||||
|
music_url: uploads.musicUrl ?? undefined,
|
||||||
|
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert ImageFiles(按顺序)
|
||||||
|
for (let i = 0; i < uploads.images.length; i++) {
|
||||||
|
const { url, width, height, video, duration } = uploads.images[i];
|
||||||
|
const durationMs =
|
||||||
|
typeof duration === "number" && Number.isFinite(duration)
|
||||||
|
? Math.max(1, Math.round(duration))
|
||||||
|
: null;
|
||||||
|
await prisma.imageFile.upsert({
|
||||||
|
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
||||||
|
create: {
|
||||||
|
postId: imagePost.aweme_id,
|
||||||
|
order: i,
|
||||||
|
url,
|
||||||
|
width: typeof width === "number" ? width : null,
|
||||||
|
height: typeof height === "number" ? height : null,
|
||||||
|
animated: video || null,
|
||||||
|
duration: durationMs,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
url,
|
||||||
|
width: typeof width === "number" ? width : null,
|
||||||
|
height: typeof height === "number" ? height : null,
|
||||||
|
animated: video || null,
|
||||||
|
duration: durationMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 评论入库:关联到 ImagePost
|
||||||
|
const comments = commentResp?.comments ?? [];
|
||||||
|
for (const c of comments) {
|
||||||
|
const origAvatar: string | null =
|
||||||
|
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||||
|
const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
|
||||||
|
const uploadedAvatar = await uploadAvatarFromUrl(
|
||||||
|
context,
|
||||||
|
origAvatar ?? undefined,
|
||||||
|
nameHint,
|
||||||
|
);
|
||||||
|
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||||
|
const finalAvatarKey = finalAvatar ?? "";
|
||||||
|
const cu = await prisma.commentUser.upsert({
|
||||||
|
where: {
|
||||||
|
nickname_avatar_url: {
|
||||||
|
nickname: c.user?.nickname || "未知用户",
|
||||||
|
avatar_url: finalAvatarKey,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
nickname: c.user?.nickname || "未知用户",
|
||||||
|
avatar_url: finalAvatar ?? null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
avatar_url: finalAvatar ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert ImagePost
|
const savedComment = await prisma.comment.upsert({
|
||||||
const imagePost = await prisma.imagePost.upsert({
|
where: { cid: c.cid },
|
||||||
where: { aweme_id: aweme.aweme_id },
|
create: {
|
||||||
create: {
|
cid: c.cid,
|
||||||
aweme_id: aweme.aweme_id,
|
text: c.text,
|
||||||
desc: aweme.desc,
|
digg_count: BigInt(c.digg_count || 0),
|
||||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
created_at: new Date((c.create_time || 0) * 1000),
|
||||||
share_url: aweme.share_url || '',
|
imagePostId: imagePost.aweme_id,
|
||||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
userId: cu.id,
|
||||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
},
|
||||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
update: {
|
||||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
text: c.text,
|
||||||
authorId: author.sec_uid,
|
digg_count: BigInt(c.digg_count || 0),
|
||||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
created_at: new Date((c.create_time || 0) * 1000),
|
||||||
music_url: uploads.musicUrl ?? null,
|
imagePostId: imagePost.aweme_id,
|
||||||
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
userId: cu.id,
|
||||||
},
|
},
|
||||||
update: {
|
|
||||||
desc: aweme.desc,
|
|
||||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
|
||||||
share_url: aweme.share_url,
|
|
||||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
|
||||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
|
||||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
|
||||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
|
||||||
authorId: author.sec_uid,
|
|
||||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
|
||||||
music_url: uploads.musicUrl ?? undefined,
|
|
||||||
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert ImageFiles(按顺序)
|
// 处理评论贴纸/配图上传与入库
|
||||||
for (let i = 0; i < uploads.images.length; i++) {
|
try {
|
||||||
const { url, width, height, video } = uploads.images[i];
|
const sources: {
|
||||||
await prisma.imageFile.upsert({
|
url?: string | null;
|
||||||
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
width?: number;
|
||||||
create: {
|
height?: number;
|
||||||
postId: imagePost.aweme_id,
|
}[] = [];
|
||||||
order: i,
|
// 贴纸(当作第一张)
|
||||||
url,
|
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
||||||
width: typeof width === 'number' ? width : null,
|
if (stickerUrl) {
|
||||||
height: typeof height === 'number' ? height : null,
|
sources.push({
|
||||||
animated: video || null,
|
url: stickerUrl,
|
||||||
},
|
width: c.sticker?.animate_url?.width,
|
||||||
update: {
|
height: c.sticker?.animate_url?.height,
|
||||||
url,
|
|
||||||
width: typeof width === 'number' ? width : null,
|
|
||||||
height: typeof height === 'number' ? height : null,
|
|
||||||
animated: video || null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
// 配图列表
|
||||||
|
const imgs = c.image_list || [];
|
||||||
|
for (const it of imgs) {
|
||||||
|
const u = firstUrl(it?.origin_url?.url_list);
|
||||||
|
if (u)
|
||||||
|
sources.push({
|
||||||
|
url: u,
|
||||||
|
width: it?.origin_url?.width as any,
|
||||||
|
height: it?.origin_url?.height as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < sources.length; i++) {
|
||||||
|
const s = sources[i];
|
||||||
|
const uploaded = await uploadImageFromUrl(
|
||||||
|
context,
|
||||||
|
s.url ?? undefined,
|
||||||
|
`comments/${c.cid}/${i}`,
|
||||||
|
);
|
||||||
|
if (!uploaded) continue;
|
||||||
|
await prisma.commentImage.upsert({
|
||||||
|
where: { commentId_order: { commentId: savedComment.cid, order: i } },
|
||||||
|
create: {
|
||||||
|
commentId: savedComment.cid,
|
||||||
|
order: i,
|
||||||
|
url: uploaded,
|
||||||
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
url: uploaded,
|
||||||
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 评论入库:关联到 ImagePost
|
return {
|
||||||
const comments = commentResp?.comments ?? [];
|
aweme_id: imagePost.aweme_id,
|
||||||
for (const c of comments) {
|
author_sec_uid: author.sec_uid,
|
||||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
image_count: uploads.images.length,
|
||||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
comment_count: comments.length,
|
||||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
};
|
||||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
|
||||||
const finalAvatarKey = finalAvatar ?? '';
|
|
||||||
const cu = await prisma.commentUser.upsert({
|
|
||||||
where: {
|
|
||||||
nickname_avatar_url: {
|
|
||||||
nickname: c.user?.nickname || '未知用户',
|
|
||||||
avatar_url: finalAvatarKey,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
nickname: c.user?.nickname || '未知用户',
|
|
||||||
avatar_url: finalAvatar ?? null,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
avatar_url: finalAvatar ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedComment = await prisma.comment.upsert({
|
|
||||||
where: { cid: c.cid },
|
|
||||||
create: {
|
|
||||||
cid: c.cid,
|
|
||||||
text: c.text,
|
|
||||||
digg_count: BigInt(c.digg_count || 0),
|
|
||||||
created_at: new Date((c.create_time || 0) * 1000),
|
|
||||||
imagePostId: imagePost.aweme_id,
|
|
||||||
userId: cu.id,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
text: c.text,
|
|
||||||
digg_count: BigInt(c.digg_count || 0),
|
|
||||||
created_at: new Date((c.create_time || 0) * 1000),
|
|
||||||
imagePostId: imagePost.aweme_id,
|
|
||||||
userId: cu.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理评论贴纸/配图上传与入库
|
|
||||||
try {
|
|
||||||
const sources: { url?: string | null; width?: number; height?: number }[] = [];
|
|
||||||
// 贴纸(当作第一张)
|
|
||||||
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
|
||||||
if (stickerUrl) {
|
|
||||||
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
|
|
||||||
}
|
|
||||||
// 配图列表
|
|
||||||
const imgs = c.image_list || [];
|
|
||||||
for (const it of imgs) {
|
|
||||||
const u = firstUrl(it?.origin_url?.url_list);
|
|
||||||
if (u) sources.push({ url: u, width: it?.origin_url?.width as any, height: it?.origin_url?.height as any });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < sources.length; i++) {
|
|
||||||
const s = sources[i];
|
|
||||||
const uploaded = await uploadImageFromUrl(
|
|
||||||
context,
|
|
||||||
s.url ?? undefined,
|
|
||||||
`comments/${c.cid}/${i}`,
|
|
||||||
);
|
|
||||||
if (!uploaded) continue;
|
|
||||||
await prisma.commentImage.upsert({
|
|
||||||
where: { commentId_order: { commentId: savedComment.cid, order: i } },
|
|
||||||
create: {
|
|
||||||
commentId: savedComment.cid,
|
|
||||||
order: i,
|
|
||||||
url: uploaded,
|
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
url: uploaded,
|
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,51 +1,51 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { scrapeDouyin, ScrapeError } from '.';
|
import { scrapeDouyin, ScrapeError } from ".";
|
||||||
|
|
||||||
async function handleDouyinScrape(req: NextRequest) {
|
async function handleDouyinScrape(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const videoUrl = searchParams.get('url');
|
const videoUrl = searchParams.get("url");
|
||||||
|
|
||||||
if (!videoUrl) {
|
if (!videoUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '缺少视频URL', code: 'MISSING_URL' },
|
{ error: "缺少视频URL", code: "MISSING_URL" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用爬虫函数
|
||||||
|
const result = await scrapeDouyin(videoUrl);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 处理自定义的 ScrapeError
|
||||||
|
if (error instanceof ScrapeError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
},
|
||||||
|
{ status: error.statusCode },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 处理未知错误
|
||||||
// 调用爬虫函数
|
console.error("未捕获的错误:", error);
|
||||||
const result = await scrapeDouyin(videoUrl);
|
return NextResponse.json(
|
||||||
return NextResponse.json({
|
{
|
||||||
success: true,
|
success: false,
|
||||||
data: result
|
error: "服务器内部错误",
|
||||||
});
|
code: "INTERNAL_ERROR",
|
||||||
} catch (error) {
|
},
|
||||||
// 处理自定义的 ScrapeError
|
{ status: 500 },
|
||||||
if (error instanceof ScrapeError) {
|
);
|
||||||
return NextResponse.json(
|
}
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
code: error.code
|
|
||||||
},
|
|
||||||
{ status: error.statusCode }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理未知错误
|
|
||||||
console.error('未捕获的错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: '服务器内部错误',
|
|
||||||
code: 'INTERNAL_ERROR'
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handleDouyinScrape
|
export const GET = handleDouyinScrape;
|
||||||
|
|||||||
66
app/api/fetcher/types.d.ts
vendored
66
app/api/fetcher/types.d.ts
vendored
@ -17,17 +17,17 @@ interface DouyinComment {
|
|||||||
animate_url: {
|
animate_url: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
url_list: string[]
|
url_list: string[];
|
||||||
}
|
};
|
||||||
},
|
};
|
||||||
|
|
||||||
image_list?: {
|
image_list?: {
|
||||||
origin_url:{
|
origin_url: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
url_list: string[]
|
url_list: string[];
|
||||||
}
|
};
|
||||||
}[]
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户信息(精简版) */
|
/** 用户信息(精简版) */
|
||||||
@ -45,35 +45,35 @@ interface DouyinVideoDetailResponse {
|
|||||||
}
|
}
|
||||||
/** 作者信息(精简版) */
|
/** 作者信息(精简版) */
|
||||||
interface DouyinAuthor {
|
interface DouyinAuthor {
|
||||||
uid: string; // 用户ID
|
uid: string; // 用户ID
|
||||||
sec_uid: string; // 安全UID
|
sec_uid: string; // 安全UID
|
||||||
nickname: string; // 用户昵称
|
nickname: string; // 用户昵称
|
||||||
signature: string; // 个性签名
|
signature: string; // 个性签名
|
||||||
avatar_thumb: {
|
avatar_thumb: {
|
||||||
url_list: string[]; // 头像URL(可取第一个)
|
url_list: string[]; // 头像URL(可取第一个)
|
||||||
};
|
};
|
||||||
follower_count: number; // 粉丝数
|
follower_count: number; // 粉丝数
|
||||||
total_favorited: number; // 获赞总数
|
total_favorited: number; // 获赞总数
|
||||||
unique_id: string; // 抖音号
|
unique_id: string; // 抖音号
|
||||||
short_id: string; // 短ID
|
short_id: string; // 短ID
|
||||||
}
|
}
|
||||||
/** 视频详情 */
|
/** 视频详情 */
|
||||||
interface DouyinVideoDetail {
|
interface DouyinVideoDetail {
|
||||||
aweme_id: string; // 视频ID
|
aweme_id: string; // 视频ID
|
||||||
desc: string; // 视频描述
|
desc: string; // 视频描述
|
||||||
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
||||||
duration: number; // 视频时长(毫秒)
|
duration: number; // 视频时长(毫秒)
|
||||||
create_time: number; // 创建时间(时间戳)
|
create_time: number; // 创建时间(时间戳)
|
||||||
share_url: string; // 视频分享链接
|
share_url: string; // 视频分享链接
|
||||||
|
|
||||||
statistics: {
|
statistics: {
|
||||||
digg_count: number; // 点赞数
|
digg_count: number; // 点赞数
|
||||||
comment_count: number; // 评论数
|
comment_count: number; // 评论数
|
||||||
share_count: number; // 分享数
|
share_count: number; // 分享数
|
||||||
collect_count: number; // 收藏数
|
collect_count: number; // 收藏数
|
||||||
};
|
};
|
||||||
|
|
||||||
author: DouyinAuthor; // 作者信息
|
author: DouyinAuthor; // 作者信息
|
||||||
video: VideoPlayBasic;
|
video: VideoPlayBasic;
|
||||||
tags: VideoTagBasic[];
|
tags: VideoTagBasic[];
|
||||||
}
|
}
|
||||||
@ -88,9 +88,9 @@ interface VideoPlayBasic {
|
|||||||
|
|
||||||
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
||||||
interface PlayVariant {
|
interface PlayVariant {
|
||||||
format: string; // mp4 等
|
format: string; // mp4 等
|
||||||
FPS: number;
|
FPS: number;
|
||||||
bit_rate: number; // bit_rate.bit_rate
|
bit_rate: number; // bit_rate.bit_rate
|
||||||
|
|
||||||
/** 直连播放地址(最关键) */
|
/** 直连播放地址(最关键) */
|
||||||
play_addr: {
|
play_addr: {
|
||||||
@ -101,7 +101,7 @@ interface PlayVariant {
|
|||||||
data_size: number;
|
data_size: number;
|
||||||
FPS: number;
|
FPS: number;
|
||||||
is_bytevc1: number; // 0 or 1
|
is_bytevc1: number; // 0 or 1
|
||||||
is_h265: number; // 0 or 1
|
is_h265: number; // 0 or 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ interface DouyinImageAweme {
|
|||||||
};
|
};
|
||||||
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
||||||
images: DouyinImageInfo[]; // 图片列表
|
images: DouyinImageInfo[]; // 图片列表
|
||||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||||
video_tag?: VideoTagBasic[]; // 标签
|
video_tag?: VideoTagBasic[]; // 标签
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,7 +143,7 @@ interface DouyinImageInfo {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
video: {
|
video: {
|
||||||
play_addr: { src: string }[]
|
play_addr: { src: string }[];
|
||||||
} | null; // 如果是动图,会有 video 信息
|
} | null; // 如果是动图,会有 video 信息
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,4 +157,4 @@ interface DouyinMusicBasic {
|
|||||||
uri?: string;
|
uri?: string;
|
||||||
url_list: string[]; // 真实可下载地址
|
url_list: string[]; // 真实可下载地址
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,117 +1,175 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from "playwright";
|
||||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
import { uploadFile, generateUniqueFileName } from "@/lib/minio";
|
||||||
import { downloadBinary } from './network';
|
import { downloadBinary } from "./network";
|
||||||
import { pickFirstUrl } from './utils';
|
import { pickFirstUrl } from "./utils";
|
||||||
import { getVideoDuration } from '@/app/api/media';
|
import { getVideoDuration } from "@/app/api/media";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||||
*/
|
*/
|
||||||
export async function uploadAvatarFromUrl(
|
export async function uploadAvatarFromUrl(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
srcUrl?: string | null,
|
srcUrl?: string | null,
|
||||||
nameHint?: string,
|
nameHint?: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (!srcUrl) return undefined;
|
if (!srcUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || "jpg";
|
||||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
||||||
const fileName = generateUniqueFileName(baseName, 'douyin/avatars');
|
const fileName = generateUniqueFileName(baseName, "douyin/avatars");
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
const uploaded = await uploadFile(buffer, fileName, {
|
||||||
return uploaded;
|
"Content-Type": contentType,
|
||||||
} catch (e) {
|
});
|
||||||
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
return uploaded;
|
||||||
return srcUrl || undefined;
|
} catch (e) {
|
||||||
}
|
console.warn(
|
||||||
|
"[avatar] 上传失败,使用原始链接:",
|
||||||
|
(e as Error)?.message || e,
|
||||||
|
);
|
||||||
|
return srcUrl || undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载任意图片并上传到 MinIO,返回外链;失败时回退为原始链接。
|
* 下载任意图片并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||||
*/
|
*/
|
||||||
export async function uploadImageFromUrl(
|
export async function uploadImageFromUrl(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
srcUrl?: string | null,
|
srcUrl?: string | null,
|
||||||
nameHint?: string,
|
nameHint?: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (!srcUrl) return undefined;
|
if (!srcUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || "jpg";
|
||||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
|
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
|
||||||
const fileName = generateUniqueFileName(baseName, 'douyin/comment-images');
|
const fileName = generateUniqueFileName(baseName, "douyin/comment-images");
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
const uploaded = await uploadFile(buffer, fileName, {
|
||||||
return uploaded;
|
"Content-Type": contentType,
|
||||||
} catch (e) {
|
});
|
||||||
console.warn('[image] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
return uploaded;
|
||||||
return srcUrl || undefined;
|
} catch (e) {
|
||||||
}
|
console.warn(
|
||||||
|
"[image] 上传失败,使用原始链接:",
|
||||||
|
(e as Error)?.message || e,
|
||||||
|
);
|
||||||
|
return srcUrl || undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
||||||
export async function handleImagePost(
|
export async function handleImagePost(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
aweme: DouyinImageAweme
|
aweme: DouyinImageAweme,
|
||||||
): Promise<{ images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }> {
|
): Promise<{
|
||||||
const awemeId = aweme.aweme_id;
|
images: {
|
||||||
const uploadedImages: { url: string; width?: number; height?: number; video?: string; duration?: number }[] = [];
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
video?: string;
|
||||||
|
duration?: number;
|
||||||
|
}[];
|
||||||
|
musicUrl?: string;
|
||||||
|
}> {
|
||||||
|
const awemeId = aweme.aweme_id;
|
||||||
|
const uploadedImages: {
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
video?: string;
|
||||||
|
duration?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// 下载图片(顺序保持)
|
// 下载图片(顺序保持)
|
||||||
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||||
const img = aweme.images[i];
|
const img = aweme.images[i];
|
||||||
const url = pickFirstUrl(img?.url_list);
|
const url = pickFirstUrl(img?.url_list);
|
||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || "jpg";
|
||||||
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
const fileName = generateUniqueFileName(
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
`${awemeId}/${i}.${safeExt}`,
|
||||||
|
"douyin/images",
|
||||||
|
);
|
||||||
|
const uploaded = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
|
||||||
if (img.video?.play_addr) {
|
if (img.video?.play_addr) {
|
||||||
// 如果是动图,下载 video 并上传
|
// 如果是动图,下载 video 并上传
|
||||||
const videoUrl = img.video.play_addr[0]?.src;
|
const videoUrl = img.video.play_addr[0]?.src;
|
||||||
if (videoUrl) {
|
if (videoUrl) {
|
||||||
try {
|
try {
|
||||||
const { buffer: videoBuffer, contentType: videoContentType, ext: videoExt } = await downloadBinary(context, videoUrl);
|
const {
|
||||||
const safeVideoExt = videoExt || 'mp4';
|
buffer: videoBuffer,
|
||||||
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images');
|
contentType: videoContentType,
|
||||||
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType });
|
ext: videoExt,
|
||||||
|
} = await downloadBinary(context, videoUrl);
|
||||||
// 获取动图时长
|
const safeVideoExt = videoExt || "mp4";
|
||||||
const duration = await getVideoDuration(videoBuffer);
|
const videoFileName = generateUniqueFileName(
|
||||||
|
`${awemeId}/${i}_animated.${safeVideoExt}`,
|
||||||
// 将动图的 video URL 和 duration 也存储起来
|
"douyin/images",
|
||||||
uploadedImages.push({
|
);
|
||||||
url: uploaded,
|
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, {
|
||||||
width: img?.width,
|
"Content-Type": videoContentType,
|
||||||
height: img?.height,
|
});
|
||||||
video: uploadedVideo,
|
|
||||||
duration: duration ?? undefined
|
// 获取动图时长
|
||||||
});
|
const duration = await getVideoDuration(videoBuffer);
|
||||||
|
|
||||||
if (duration) {
|
// 将动图的 video URL 和 duration 也存储起来
|
||||||
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
|
uploadedImages.push({
|
||||||
}
|
url: uploaded,
|
||||||
} catch (e) {
|
width: img?.width,
|
||||||
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e);
|
height: img?.height,
|
||||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
video: uploadedVideo,
|
||||||
}
|
duration: duration ?? undefined,
|
||||||
}
|
});
|
||||||
} else {
|
|
||||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
if (duration) {
|
||||||
|
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`[image] 动图视频上传失败,跳过:`,
|
||||||
|
(e as Error)?.message || e,
|
||||||
|
);
|
||||||
|
uploadedImages.push({
|
||||||
|
url: uploaded,
|
||||||
|
width: img?.width,
|
||||||
|
height: img?.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uploadedImages.push({
|
||||||
|
url: uploaded,
|
||||||
|
width: img?.width,
|
||||||
|
height: img?.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 下载音乐(可选)
|
// 下载音乐(可选)
|
||||||
let musicUrl: string | undefined;
|
let musicUrl: string | undefined;
|
||||||
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
||||||
if (audioSrc) {
|
if (audioSrc) {
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc);
|
const { buffer, contentType, ext } = await downloadBinary(
|
||||||
const safeExt = ext || 'mp3';
|
context,
|
||||||
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios');
|
audioSrc,
|
||||||
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
);
|
||||||
}
|
const safeExt = ext || "mp3";
|
||||||
|
const fileName = generateUniqueFileName(
|
||||||
|
`${awemeId}.${safeExt}`,
|
||||||
|
"douyin/audios",
|
||||||
|
);
|
||||||
|
musicUrl = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { images: uploadedImages, musicUrl };
|
return { images: uploadedImages, musicUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export function toCamelCaseKey(key: string): string {
|
export function toCamelCaseKey(key: string): string {
|
||||||
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toSnakeCaseKey(key: string): string {
|
export function toSnakeCaseKey(key: string): string {
|
||||||
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,52 +13,53 @@ export function toSnakeCaseKey(key: string): string {
|
|||||||
* 访问顺序:原名 -> camelCase -> snake_case
|
* 访问顺序:原名 -> camelCase -> snake_case
|
||||||
*/
|
*/
|
||||||
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
||||||
const seen = new WeakMap<object, any>();
|
const seen = new WeakMap<object, any>();
|
||||||
|
|
||||||
const wrap = (value: any): any => {
|
const wrap = (value: any): any => {
|
||||||
if (value === null || typeof value !== 'object') return value;
|
if (value === null || typeof value !== "object") return value;
|
||||||
if (seen.has(value)) return seen.get(value);
|
if (seen.has(value)) return seen.get(value);
|
||||||
const proxied = new Proxy(value, handler);
|
const proxied = new Proxy(value, handler);
|
||||||
seen.set(value, proxied);
|
seen.set(value, proxied);
|
||||||
return proxied;
|
return proxied;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handler: ProxyHandler<any> = {
|
const handler: ProxyHandler<any> = {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver) {
|
||||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||||
if (typeof prop !== 'string') {
|
if (typeof prop !== "string") {
|
||||||
return wrap(Reflect.get(target, prop, receiver));
|
return wrap(Reflect.get(target, prop, receiver));
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = prop;
|
const primary = prop;
|
||||||
|
|
||||||
if (primary in target) return wrap(Reflect.get(target, primary, receiver));
|
if (primary in target)
|
||||||
|
return wrap(Reflect.get(target, primary, receiver));
|
||||||
|
|
||||||
const camel = toCamelCaseKey(primary);
|
const camel = toCamelCaseKey(primary);
|
||||||
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
||||||
|
|
||||||
const snake = toSnakeCaseKey(primary);
|
const snake = toSnakeCaseKey(primary);
|
||||||
if (snake in target) return wrap(Reflect.get(target, snake, receiver));
|
if (snake in target) return wrap(Reflect.get(target, snake, receiver));
|
||||||
|
|
||||||
return wrap(Reflect.get(target, prop, receiver));
|
return wrap(Reflect.get(target, prop, receiver));
|
||||||
},
|
},
|
||||||
has(target, prop) {
|
has(target, prop) {
|
||||||
if (typeof prop !== 'string') return prop in target;
|
if (typeof prop !== "string") return prop in target;
|
||||||
const primary = prop === 'auther' ? 'autherInfo' : prop;
|
const primary = prop === "auther" ? "autherInfo" : prop;
|
||||||
return (
|
return (
|
||||||
primary in target ||
|
primary in target ||
|
||||||
toCamelCaseKey(primary) in target ||
|
toCamelCaseKey(primary) in target ||
|
||||||
toSnakeCaseKey(primary) in target
|
toSnakeCaseKey(primary) in target
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return wrap(root);
|
return wrap(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选择首个可用 URL */
|
/** 选择首个可用 URL */
|
||||||
export function pickFirstUrl(list?: string[]) {
|
export function pickFirstUrl(list?: string[]) {
|
||||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 别名,兼容旧命名
|
// 别名,兼容旧命名
|
||||||
|
|||||||
299
app/api/media.ts
299
app/api/media.ts
@ -1,80 +1,113 @@
|
|||||||
import { execFile } from 'child_process';
|
import { execFile } from "child_process";
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from "fs";
|
||||||
import os from 'os';
|
import os from "os";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import { promisify } from 'util';
|
import { promisify } from "util";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||||
*/
|
*/
|
||||||
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
export async function extractFirstFrame(
|
||||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
videoBuffer: Buffer,
|
||||||
const tmpDir = os.tmpdir();
|
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
const tmpDir = os.tmpdir();
|
||||||
const outPath = path.join(tmpDir, `${base}.jpg`);
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
|
const outPath = path.join(tmpDir, `${base}.jpg`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(inPath, videoBuffer);
|
await fs.writeFile(inPath, videoBuffer);
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
"-hide_banner",
|
||||||
'-loglevel', 'error',
|
"-loglevel",
|
||||||
'-ss', '0',
|
"error",
|
||||||
'-i', inPath,
|
"-ss",
|
||||||
'-frames:v', '1',
|
"0",
|
||||||
'-q:v', '2',
|
"-i",
|
||||||
'-f', 'image2',
|
inPath,
|
||||||
'-y',
|
"-frames:v",
|
||||||
outPath,
|
"1",
|
||||||
];
|
"-q:v",
|
||||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
"2",
|
||||||
const img = await fs.readFile(outPath);
|
"-f",
|
||||||
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' };
|
"image2",
|
||||||
} catch (e: any) {
|
"-y",
|
||||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
outPath,
|
||||||
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。');
|
];
|
||||||
return null;
|
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||||
}
|
const img = await fs.readFile(outPath);
|
||||||
throw e;
|
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
|
||||||
} finally {
|
} catch (e: any) {
|
||||||
try { await fs.unlink(inPath); } catch { }
|
if (
|
||||||
try { await fs.unlink(outPath); } catch { }
|
e &&
|
||||||
|
(e.code === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(String(e.message)))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fs.unlink(inPath);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await fs.unlink(outPath);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffprobe 获取视频时长(毫秒)
|
* 使用 ffprobe 获取视频时长(毫秒)
|
||||||
*/
|
*/
|
||||||
export async function getVideoDuration(videoBuffer: Buffer): Promise<number | null> {
|
export async function getVideoDuration(
|
||||||
const ffprobeCmd = process.env.FFPROBE_PATH || 'ffprobe';
|
videoBuffer: Buffer,
|
||||||
const tmpDir = os.tmpdir();
|
): Promise<number | null> {
|
||||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe";
|
||||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
const tmpDir = os.tmpdir();
|
||||||
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(inPath, videoBuffer);
|
await fs.writeFile(inPath, videoBuffer);
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
"-v",
|
||||||
'-show_entries', 'format=duration',
|
"error",
|
||||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
"-show_entries",
|
||||||
inPath,
|
"format=duration",
|
||||||
];
|
"-of",
|
||||||
const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true });
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
const durationSeconds = parseFloat(stdout.trim());
|
inPath,
|
||||||
if (isNaN(durationSeconds)) return null;
|
];
|
||||||
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
const { stdout } = await execFileAsync(ffprobeCmd, args, {
|
||||||
} catch (e: any) {
|
windowsHide: true,
|
||||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
});
|
||||||
console.warn('系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。');
|
const durationSeconds = parseFloat(stdout.trim());
|
||||||
return null;
|
if (isNaN(durationSeconds)) return null;
|
||||||
}
|
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
||||||
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
} catch (e: any) {
|
||||||
return null;
|
if (
|
||||||
} finally {
|
e &&
|
||||||
try { await fs.unlink(inPath); } catch { }
|
(e.code === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(String(e.message)))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fs.unlink(inPath);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -88,68 +121,94 @@ export async function getVideoDuration(videoBuffer: Buffer): Promise<number | nu
|
|||||||
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
||||||
*/
|
*/
|
||||||
export async function extractAudio(
|
export async function extractAudio(
|
||||||
videoBuffer: Buffer,
|
videoBuffer: Buffer,
|
||||||
opts?: { format?: 'mp3' | 'aac' | 'wav'; bitrateKbps?: number }
|
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
|
||||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||||
const tmpDir = os.tmpdir();
|
const tmpDir = os.tmpdir();
|
||||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
|
|
||||||
const format = opts?.format ?? 'mp3';
|
const format = opts?.format ?? "mp3";
|
||||||
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
|
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
|
||||||
|
|
||||||
// 根据目标格式设置输出路径、MIME 与编码参数
|
// 根据目标格式设置输出路径、MIME 与编码参数
|
||||||
let outPath = '';
|
let outPath = "";
|
||||||
let contentType = '';
|
let contentType = "";
|
||||||
let ext = '';
|
let ext = "";
|
||||||
let codecArgs: string[] = [];
|
let codecArgs: string[] = [];
|
||||||
|
|
||||||
if (format === 'mp3') {
|
if (format === "mp3") {
|
||||||
ext = 'mp3';
|
ext = "mp3";
|
||||||
contentType = 'audio/mpeg';
|
contentType = "audio/mpeg";
|
||||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||||
codecArgs = ['-c:a', 'libmp3lame', '-b:a', `${bitrate}k`];
|
codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`];
|
||||||
} else if (format === 'aac') {
|
} else if (format === "aac") {
|
||||||
// 使用 m4a 容器更通用
|
// 使用 m4a 容器更通用
|
||||||
ext = 'm4a';
|
ext = "m4a";
|
||||||
contentType = 'audio/mp4';
|
contentType = "audio/mp4";
|
||||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||||
codecArgs = ['-c:a', 'aac', '-b:a', `${bitrate}k`, '-movflags', '+faststart'];
|
codecArgs = [
|
||||||
} else {
|
"-c:a",
|
||||||
// wav
|
"aac",
|
||||||
ext = 'wav';
|
"-b:a",
|
||||||
contentType = 'audio/wav';
|
`${bitrate}k`,
|
||||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
"-movflags",
|
||||||
codecArgs = ['-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2'];
|
"+faststart",
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// wav
|
||||||
|
ext = "wav";
|
||||||
|
contentType = "audio/wav";
|
||||||
|
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||||
|
codecArgs = [
|
||||||
|
"-f",
|
||||||
|
"wav",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
"44100",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(inPath, videoBuffer);
|
||||||
|
const args = [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
inPath,
|
||||||
|
"-vn", // 丢弃视频流
|
||||||
|
...codecArgs,
|
||||||
|
"-y",
|
||||||
|
outPath,
|
||||||
|
];
|
||||||
|
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||||
|
const audio = await fs.readFile(outPath);
|
||||||
|
return { buffer: audio, contentType, ext };
|
||||||
|
} catch (e: any) {
|
||||||
|
if (
|
||||||
|
e &&
|
||||||
|
(e.code === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(String(e.message)))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
||||||
|
console.warn(`提取音频失败: ${e?.message || e}`);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(inPath, videoBuffer);
|
await fs.unlink(inPath);
|
||||||
const args = [
|
} catch {}
|
||||||
'-hide_banner',
|
try {
|
||||||
'-loglevel', 'error',
|
if (outPath) await fs.unlink(outPath);
|
||||||
'-i', inPath,
|
} catch {}
|
||||||
'-vn', // 丢弃视频流
|
}
|
||||||
...codecArgs,
|
|
||||||
'-y',
|
|
||||||
outPath,
|
|
||||||
];
|
|
||||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
|
||||||
const audio = await fs.readFile(outPath);
|
|
||||||
return { buffer: audio, contentType, ext };
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
|
||||||
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
|
||||||
console.warn(`提取音频失败: ${e?.message || e}`);
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
try { await fs.unlink(inPath); } catch { }
|
|
||||||
try {
|
|
||||||
if (outPath) await fs.unlink(outPath);
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import { json } from '@/lib/json';
|
import { json } from "@/lib/json";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { prisma } from '@/lib/prisma'; // 你的 Prisma 客户端实例
|
import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = (searchParams.get('q') || '').trim();
|
const q = (searchParams.get("q") || "").trim();
|
||||||
const page = Math.max(1, Number(searchParams.get('page') || 1));
|
const page = Math.max(1, Number(searchParams.get("page") || 1));
|
||||||
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit') || 20)));
|
const limit = Math.min(
|
||||||
|
50,
|
||||||
|
Math.max(1, Number(searchParams.get("limit") || 20)),
|
||||||
|
);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
@ -24,7 +27,7 @@ export async function GET(req: Request) {
|
|||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
type: 'video' | 'image';
|
type: "video" | "image";
|
||||||
rank: number;
|
rank: number;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
}[]
|
}[]
|
||||||
@ -86,9 +89,11 @@ export async function GET(req: Request) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// 查询总数(参数化,避免注入)
|
// 查询总数(参数化,避免注入)
|
||||||
const totalRows = await prisma.$queryRaw<{
|
const totalRows = await prisma.$queryRaw<
|
||||||
count: number;
|
{
|
||||||
}[]>`
|
count: number;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
WITH tsq AS (
|
WITH tsq AS (
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||||
)
|
)
|
||||||
@ -104,61 +109,88 @@ export async function GET(req: Request) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// 分离视频和图文ID
|
// 分离视频和图文ID
|
||||||
const videoIds = rows.filter(r => r.type === 'video').map(r => r.awemeId);
|
const videoIds = rows
|
||||||
const imagePostIds = rows.filter(r => r.type === 'image').map(r => r.awemeId);
|
.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({
|
const videos =
|
||||||
where: { aweme_id: { in: videoIds } },
|
videoIds.length > 0
|
||||||
select: {
|
? (
|
||||||
aweme_id: true,
|
await prisma.video.findMany({
|
||||||
desc: true,
|
where: { aweme_id: { in: videoIds } },
|
||||||
cover_url: true,
|
select: {
|
||||||
video_url: true,
|
aweme_id: true,
|
||||||
duration_ms: true,
|
desc: true,
|
||||||
author: true
|
cover_url: true,
|
||||||
},
|
video_url: true,
|
||||||
})).map(v => (
|
duration_ms: true,
|
||||||
{ ...v, cover_url: getFileUrl(v.cover_url || ''),
|
author: true,
|
||||||
author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || '') },
|
},
|
||||||
video_url: getFileUrl(v.video_url || '') })
|
})
|
||||||
) : [];
|
).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({
|
const imagePosts =
|
||||||
where: { aweme_id: { in: imagePostIds } },
|
imagePostIds.length > 0
|
||||||
select: {
|
? (
|
||||||
aweme_id: true,
|
await prisma.imagePost.findMany({
|
||||||
desc: true,
|
where: { aweme_id: { in: imagePostIds } },
|
||||||
author: true,
|
select: {
|
||||||
images: {
|
aweme_id: true,
|
||||||
orderBy: { order: 'asc' },
|
desc: true,
|
||||||
take: 1,
|
author: true,
|
||||||
select: {
|
images: {
|
||||||
url: true,
|
orderBy: { order: "asc" },
|
||||||
width: true,
|
take: 1,
|
||||||
height: true,
|
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,
|
})
|
||||||
})) : [];
|
).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({
|
return json({
|
||||||
results: rows.map(r => ({
|
results: rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
video: r.type === 'video' ? videos.find(v => v.aweme_id === r.awemeId) : undefined,
|
video:
|
||||||
imagePost: r.type === 'image' ? imagePosts.find(ip => ip.aweme_id === r.awemeId) : undefined,
|
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,
|
total: totalRows?.[0]?.count ?? 0,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search error:', err);
|
console.error("Search error:", err);
|
||||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
return NextResponse.json({ error: "Search failed" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ async function transcriptAudio(audio: Buffer | string) {
|
|||||||
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = completion.choices?.[0]?.message
|
const data = completion.choices?.[0]?.message;
|
||||||
console.log("转写结果", data.content);
|
console.log("转写结果", data.content);
|
||||||
|
|
||||||
if (!data || !data.content) {
|
if (!data || !data.content) {
|
||||||
@ -74,10 +74,13 @@ export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
|||||||
if (!aweme) {
|
if (!aweme) {
|
||||||
throw new Error("Aweme not found or aweme is not a video post");
|
throw new Error("Aweme not found or aweme is not a video post");
|
||||||
}
|
}
|
||||||
const vPath = aweme.video_url
|
const vPath = aweme.video_url;
|
||||||
const buffer = await downloadFile(vPath);
|
const buffer = await downloadFile(vPath);
|
||||||
|
|
||||||
const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 128 });
|
const audioDat = await extractAudio(buffer, {
|
||||||
|
format: "mp3",
|
||||||
|
bitrateKbps: 128,
|
||||||
|
});
|
||||||
|
|
||||||
if (!audioDat || !audioDat.buffer) {
|
if (!audioDat || !audioDat.buffer) {
|
||||||
throw new Error("Failed to extract audio from video");
|
throw new Error("Failed to extract audio from video");
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { transcriptAweme } from '.';
|
import { transcriptAweme } from ".";
|
||||||
|
|
||||||
// Contract
|
// Contract
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
// Inputs: search params { before?: ISOString, limit?: number }
|
||||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url);
|
||||||
const awemeId = searchParams.get('awemeId');
|
const awemeId = searchParams.get("awemeId");
|
||||||
if (!awemeId) {
|
if (!awemeId) {
|
||||||
return NextResponse.json({ error: "Missing awemeId parameter" }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: "Missing awemeId parameter" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const script = await transcriptAweme(awemeId);
|
const script = await transcriptAweme(awemeId);
|
||||||
return NextResponse.json(script);
|
return NextResponse.json(script);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { FeedItem } from "@/app/types/feed";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
export default async function AuthorPage({ params }: { params: Promise<{ secUid: string }> }) {
|
export default async function AuthorPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ secUid: string }>;
|
||||||
|
}) {
|
||||||
const secUid = (await params).secUid;
|
const secUid = (await params).secUid;
|
||||||
|
|
||||||
const author = await prisma.author.findUnique({
|
const author = await prisma.author.findUnique({
|
||||||
@ -22,15 +26,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: { authorId: secUid },
|
where: { authorId: secUid },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: { author: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
prisma.imagePost.findMany({
|
||||||
where: { authorId: secUid },
|
where: { authorId: secUid },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -41,11 +45,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
@ -55,13 +63,23 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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))
|
]
|
||||||
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const initialCursor = initialItems.length > 0 ? new Date(initialItems[initialItems.length - 1].created_at as any).toISOString() : null;
|
const initialCursor =
|
||||||
|
initialItems.length > 0
|
||||||
|
? new Date(
|
||||||
|
initialItems[initialItems.length - 1].created_at as any,
|
||||||
|
).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
|
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
|
||||||
@ -75,21 +93,21 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
|
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
|
||||||
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
|
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={getFileUrl(author.avatar_url || 'default-avatar.png')}
|
src={getFileUrl(author.avatar_url || "default-avatar.png")}
|
||||||
alt={author.nickname}
|
alt={author.nickname}
|
||||||
fill
|
fill
|
||||||
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
|
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 text-center md:text-left space-y-4">
|
<div className="flex-1 text-center md:text-left space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">{author.nickname}</h2>
|
<h2 className="text-2xl font-bold">{author.nickname}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
抖音号:{author.unique_id || author.short_id || '未知'}
|
抖音号:{author.unique_id || author.short_id || "未知"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{author.signature && (
|
{author.signature && (
|
||||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-w-2xl">
|
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-w-2xl">
|
||||||
{author.signature}
|
{author.signature}
|
||||||
@ -98,11 +116,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
|
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-bold text-lg">{Number(author.total_favorited).toLocaleString()}</span>
|
<span className="font-bold text-lg">
|
||||||
|
{Number(author.total_favorited).toLocaleString()}
|
||||||
|
</span>
|
||||||
<span className="text-gray-500">获赞</span>
|
<span className="text-gray-500">获赞</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-bold text-lg">{Number(author.follower_count).toLocaleString()}</span>
|
<span className="font-bold text-lg">
|
||||||
|
{Number(author.follower_count).toLocaleString()}
|
||||||
|
</span>
|
||||||
<span className="text-gray-500">粉丝</span>
|
<span className="text-gray-500">粉丝</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -111,9 +133,9 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-8">
|
<div className="border-t border-gray-200 dark:border-gray-800 pt-8">
|
||||||
<h3 className="text-lg font-semibold mb-6">作品</h3>
|
<h3 className="text-lg font-semibold mb-6">作品</h3>
|
||||||
<FeedMasonry
|
<FeedMasonry
|
||||||
initialItems={initialItems}
|
initialItems={initialItems}
|
||||||
initialCursor={initialCursor}
|
initialCursor={initialCursor}
|
||||||
fetchUrl={`/api/author/${secUid}/feed`}
|
fetchUrl={`/api/author/${secUid}/feed`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Pause, Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
import type { AwemeData, ImageData, Neighbors, VideoData, VideoTranscript } from "./types.ts";
|
import type {
|
||||||
|
AwemeData,
|
||||||
|
ImageData,
|
||||||
|
Neighbors,
|
||||||
|
VideoData,
|
||||||
|
VideoTranscript,
|
||||||
|
} from "./types.ts";
|
||||||
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
||||||
import { CommentPanel } from "./components/CommentPanel";
|
import { CommentPanel } from "./components/CommentPanel";
|
||||||
import { ImageCarousel } from "./components/ImageCarousel";
|
import { ImageCarousel } from "./components/ImageCarousel";
|
||||||
@ -18,7 +24,6 @@ import { useImageCarousel } from "./hooks/useImageCarousel";
|
|||||||
import { useNavigation } from "./hooks/useNavigation";
|
import { useNavigation } from "./hooks/useNavigation";
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
const SEGMENT_MS = 4000;
|
const SEGMENT_MS = 4000;
|
||||||
|
|
||||||
@ -28,7 +33,11 @@ interface AwemeDetailClientProps {
|
|||||||
transcript: VideoTranscript | null;
|
transcript: VideoTranscript | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) {
|
export default function AwemeDetailClient({
|
||||||
|
data,
|
||||||
|
neighbors,
|
||||||
|
transcript,
|
||||||
|
}: AwemeDetailClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isVideo = data.type === "video";
|
const isVideo = data.type === "video";
|
||||||
|
|
||||||
@ -44,6 +53,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const pauseImageMedia = useCallback((reset = false) => {
|
||||||
|
const audioEl = audioRef.current;
|
||||||
|
if (audioEl) {
|
||||||
|
audioEl.pause();
|
||||||
|
if (reset) audioEl.currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollerRef.current?.querySelectorAll("video").forEach((videoEl) => {
|
||||||
|
videoEl.pause();
|
||||||
|
if (reset) videoEl.currentTime = 0;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 图文轮播状态
|
// 图文轮播状态
|
||||||
const images = isVideo ? [] : (data as ImageData).images;
|
const images = isVideo ? [] : (data as ImageData).images;
|
||||||
const imageCarouselState = useImageCarousel({
|
const imageCarouselState = useImageCarousel({
|
||||||
@ -53,7 +75,6 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
neighbors,
|
neighbors,
|
||||||
volume: playerState.volume,
|
volume: playerState.volume,
|
||||||
audioRef,
|
audioRef,
|
||||||
scrollerRef,
|
|
||||||
setProgress: playerState.setProgress,
|
setProgress: playerState.setProgress,
|
||||||
segmentMs: SEGMENT_MS,
|
segmentMs: SEGMENT_MS,
|
||||||
});
|
});
|
||||||
@ -84,50 +105,32 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!images?.length) return;
|
if (!images?.length) return;
|
||||||
|
imageCarouselState.seekTo(ratio);
|
||||||
|
};
|
||||||
|
|
||||||
// 计算每张图片的时长
|
const seekImageByVisualRatio = (ratio: number) => {
|
||||||
const durations = images.map(img => img.duration ?? SEGMENT_MS);
|
if (!images?.length) return;
|
||||||
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
|
||||||
const targetTime = ratio * totalDuration;
|
|
||||||
|
|
||||||
// 找到目标时间对应的图片索引和进度
|
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||||
let accumulatedTime = 0;
|
const rawSegment = clampedRatio * images.length;
|
||||||
let targetIdx = 0;
|
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
|
||||||
let remainder = 0;
|
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
||||||
|
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
||||||
|
};
|
||||||
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
const handleControlSeek = (ratio: number) => {
|
||||||
if (accumulatedTime + durations[i] > targetTime) {
|
if (isVideo) {
|
||||||
targetIdx = i;
|
seekTo(ratio);
|
||||||
remainder = (targetTime - accumulatedTime) / durations[i];
|
return;
|
||||||
break;
|
|
||||||
}
|
|
||||||
accumulatedTime += durations[i];
|
|
||||||
if (i === images.length - 1) {
|
|
||||||
targetIdx = i;
|
|
||||||
remainder = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
seekImageByVisualRatio(ratio);
|
||||||
imageCarouselState.idxRef.current = targetIdx;
|
|
||||||
imageCarouselState.setIdx(targetIdx);
|
|
||||||
imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
|
|
||||||
|
|
||||||
// 重新计算总进度
|
|
||||||
let totalProgress = 0;
|
|
||||||
for (let i = 0; i < targetIdx; i++) {
|
|
||||||
totalProgress += 1;
|
|
||||||
}
|
|
||||||
totalProgress += remainder;
|
|
||||||
playerState.setProgress(totalProgress / images.length);
|
|
||||||
|
|
||||||
// 虚拟滚动不需要实际滚动 DOM
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (v.paused) await v.play().catch(() => { });
|
if (v.paused) await v.play().catch(() => {});
|
||||||
else v.pause();
|
else v.pause();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -135,23 +138,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
if (!playerState.isPlaying) {
|
if (!playerState.isPlaying) {
|
||||||
playerState.setIsPlaying(true);
|
playerState.setIsPlaying(true);
|
||||||
try {
|
try {
|
||||||
await el?.play().catch(() => { });
|
await el?.play().catch(() => {});
|
||||||
} catch { }
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
playerState.setIsPlaying(false);
|
playerState.setIsPlaying(false);
|
||||||
el?.pause();
|
pauseImageMedia();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
if (document.body.requestFullscreen) {
|
if (document.body.requestFullscreen) {
|
||||||
document.body.requestFullscreen().catch(() => { });
|
document.body.requestFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const vRef = videoRef.current;
|
const vRef = videoRef.current;
|
||||||
if (vRef && vRef.requestFullscreen) {
|
if (vRef && vRef.requestFullscreen) {
|
||||||
vRef.requestFullscreen().catch(() => { });
|
vRef.requestFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -160,26 +163,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
vRef.webkitEnterFullscreen();
|
vRef.webkitEnterFullscreen();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
document.exitFullscreen().catch(() => { });
|
document.exitFullscreen().catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevImg = () => {
|
const prevImg = () => {
|
||||||
if (!images?.length) return;
|
if (!images?.length) return;
|
||||||
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
||||||
imageCarouselState.idxRef.current = next;
|
imageCarouselState.goToIndex(next);
|
||||||
imageCarouselState.setIdx(next);
|
|
||||||
imageCarouselState.segStartRef.current = performance.now();
|
|
||||||
// 虚拟滚动不需要实际滚动 DOM
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextImg = () => {
|
const nextImg = () => {
|
||||||
if (!images?.length) return;
|
if (!images?.length) return;
|
||||||
const next = Math.min(images.length - 1, imageCarouselState.idxRef.current + 1);
|
const next = Math.min(
|
||||||
imageCarouselState.idxRef.current = next;
|
images.length - 1,
|
||||||
imageCarouselState.setIdx(next);
|
imageCarouselState.idxRef.current + 1,
|
||||||
imageCarouselState.segStartRef.current = performance.now();
|
);
|
||||||
// 虚拟滚动不需要实际滚动 DOM
|
imageCarouselState.goToIndex(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
@ -223,6 +223,27 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
backgroundCanvasRef,
|
backgroundCanvasRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVideo) return;
|
||||||
|
|
||||||
|
const pauseForPageLifecycle = () => {
|
||||||
|
playerState.setIsPlaying(false);
|
||||||
|
pauseImageMedia();
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) pauseForPageLifecycle();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", pauseForPageLifecycle);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", pauseForPageLifecycle);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
pauseImageMedia();
|
||||||
|
};
|
||||||
|
}, [isVideo, pauseImageMedia, playerState.setIsPlaying]);
|
||||||
|
|
||||||
// Media Session API 集成
|
// Media Session API 集成
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@ -236,20 +257,28 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
const album = new Date(data.created_at).toLocaleString();
|
const album = new Date(data.created_at).toLocaleString();
|
||||||
// 单一封面图:使用作品 cover_url
|
// 单一封面图:使用作品 cover_url
|
||||||
const coverUrl = (data as AwemeData).cover_url as string | undefined;
|
const coverUrl = (data as AwemeData).cover_url as string | undefined;
|
||||||
const coverSize = (data as AwemeData).cover_size as { w: number; h: number } | undefined;
|
const coverSize = (data as AwemeData).cover_size as
|
||||||
const artwork = coverUrl ? [{ src: coverUrl, size: `${coverSize?.w || 512}x${coverSize?.h || 512}` }] : [];
|
| { w: number; h: number }
|
||||||
|
| undefined;
|
||||||
|
const artwork = coverUrl
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
src: coverUrl,
|
||||||
|
size: `${coverSize?.w || 512}x${coverSize?.h || 512}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ms.metadata = new MediaMetadata({ title, artist, album, artwork });
|
ms.metadata = new MediaMetadata({ title, artist, album, artwork });
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
// 更新播放状态
|
// 更新播放状态
|
||||||
try {
|
try {
|
||||||
ms.playbackState = playerState.isPlaying ? "playing" : "paused";
|
ms.playbackState = playerState.isPlaying ? "playing" : "paused";
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
const getImagesTotalMs = () =>
|
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
||||||
(images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0);
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
try {
|
try {
|
||||||
@ -267,11 +296,14 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
} else if (images?.length) {
|
} else if (images?.length) {
|
||||||
const totalMs = getImagesTotalMs();
|
const totalMs = getImagesTotalMs();
|
||||||
const duration = totalMs / 1000;
|
const duration = totalMs / 1000;
|
||||||
const position = Math.max(0, Math.min(duration, (playerState.progress * totalMs) / 1000));
|
const position = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
||||||
|
);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
ms.setPositionState({ duration, position, playbackRate: 1 });
|
ms.setPositionState({ duration, position, playbackRate: 1 });
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 绑定视频事件以同步状态
|
// 绑定视频事件以同步状态
|
||||||
@ -279,15 +311,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (isVideo && v) {
|
if (isVideo && v) {
|
||||||
const onPlay = () => {
|
const onPlay = () => {
|
||||||
try { ms.playbackState = "playing"; } catch { }
|
try {
|
||||||
|
ms.playbackState = "playing";
|
||||||
|
} catch {}
|
||||||
updatePosition();
|
updatePosition();
|
||||||
};
|
};
|
||||||
const onPause = () => {
|
const onPause = () => {
|
||||||
try { ms.playbackState = "paused"; } catch { }
|
try {
|
||||||
|
ms.playbackState = "paused";
|
||||||
|
} catch {}
|
||||||
updatePosition();
|
updatePosition();
|
||||||
};
|
};
|
||||||
const onTimeUpdate = () => updatePosition();
|
const onTimeUpdate = () => updatePosition();
|
||||||
|
|
||||||
v.addEventListener("play", onPlay);
|
v.addEventListener("play", onPlay);
|
||||||
v.addEventListener("pause", onPause);
|
v.addEventListener("pause", onPause);
|
||||||
v.addEventListener("timeupdate", onTimeUpdate);
|
v.addEventListener("timeupdate", onTimeUpdate);
|
||||||
@ -317,6 +353,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
data.author.avatar_url,
|
data.author.avatar_url,
|
||||||
data.created_at,
|
data.created_at,
|
||||||
imageCarouselState.idx,
|
imageCarouselState.idx,
|
||||||
|
imageCarouselState.totalDurationMs,
|
||||||
images,
|
images,
|
||||||
playerState.isPlaying,
|
playerState.isPlaying,
|
||||||
playerState.progress,
|
playerState.progress,
|
||||||
@ -329,16 +366,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
if (!("mediaSession" in navigator)) return;
|
if (!("mediaSession" in navigator)) return;
|
||||||
const ms = (navigator as any).mediaSession as MediaSession;
|
const ms = (navigator as any).mediaSession as MediaSession;
|
||||||
|
|
||||||
const getImagesTotalMs = () =>
|
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
||||||
(images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0);
|
|
||||||
|
|
||||||
const handlePlay = async () => {
|
const handlePlay = async () => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
try { await v?.play(); } catch { }
|
try {
|
||||||
|
await v?.play();
|
||||||
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
playerState.setIsPlaying(true);
|
playerState.setIsPlaying(true);
|
||||||
try { await audioRef.current?.play(); } catch { }
|
try {
|
||||||
|
await audioRef.current?.play();
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handlePause = () => {
|
const handlePause = () => {
|
||||||
@ -347,23 +387,30 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
v?.pause();
|
v?.pause();
|
||||||
} else {
|
} else {
|
||||||
playerState.setIsPlaying(false);
|
playerState.setIsPlaying(false);
|
||||||
audioRef.current?.pause();
|
pauseImageMedia();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleStop = () => {
|
const handleStop = () => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) { v.pause(); v.currentTime = 0; }
|
if (v) {
|
||||||
|
v.pause();
|
||||||
|
v.currentTime = 0;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
playerState.setIsPlaying(false);
|
playerState.setIsPlaying(false);
|
||||||
audioRef.current?.pause();
|
pauseImageMedia(true);
|
||||||
seekTo(0);
|
seekTo(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleSeekDelta = (deltaSec: number) => {
|
const handleSeekDelta = (deltaSec: number) => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.currentTime = Math.max(0, Math.min(v.duration || Infinity, v.currentTime + deltaSec));
|
if (v)
|
||||||
|
v.currentTime = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(v.duration || Infinity, v.currentTime + deltaSec),
|
||||||
|
);
|
||||||
} else if (images?.length) {
|
} else if (images?.length) {
|
||||||
const totalMs = getImagesTotalMs();
|
const totalMs = getImagesTotalMs();
|
||||||
if (totalMs <= 0) return;
|
if (totalMs <= 0) return;
|
||||||
@ -387,8 +434,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
ms.setActionHandler("play", handlePlay);
|
ms.setActionHandler("play", handlePlay);
|
||||||
ms.setActionHandler("pause", handlePause);
|
ms.setActionHandler("pause", handlePause);
|
||||||
ms.setActionHandler("stop", handleStop);
|
ms.setActionHandler("stop", handleStop);
|
||||||
ms.setActionHandler("seekbackward", (details: any) => handleSeekDelta(-((details?.seekOffset as number) || 10)));
|
ms.setActionHandler("seekbackward", (details: any) =>
|
||||||
ms.setActionHandler("seekforward", (details: any) => handleSeekDelta((details?.seekOffset as number) || 10));
|
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
||||||
|
);
|
||||||
|
ms.setActionHandler("seekforward", (details: any) =>
|
||||||
|
handleSeekDelta((details?.seekOffset as number) || 10),
|
||||||
|
);
|
||||||
ms.setActionHandler("seekto", (details: any) => {
|
ms.setActionHandler("seekto", (details: any) => {
|
||||||
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
||||||
});
|
});
|
||||||
@ -417,7 +468,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
ms.setActionHandler("seekto", null);
|
ms.setActionHandler("seekto", null);
|
||||||
ms.setActionHandler("previoustrack", null);
|
ms.setActionHandler("previoustrack", null);
|
||||||
ms.setActionHandler("nexttrack", null);
|
ms.setActionHandler("nexttrack", null);
|
||||||
} catch { }
|
} catch {}
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
isVideo,
|
isVideo,
|
||||||
@ -426,6 +477,8 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
neighbors.next,
|
neighbors.next,
|
||||||
router,
|
router,
|
||||||
playerState.progress,
|
playerState.progress,
|
||||||
|
pauseImageMedia,
|
||||||
|
imageCarouselState.totalDurationMs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -434,29 +487,39 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
|
|
||||||
<div className="relative h-full landscape:flex landscape:flex-row">
|
<div className="relative h-full landscape:flex landscape:flex-row">
|
||||||
{/* 主媒体区域 */}
|
{/* 主媒体区域 */}
|
||||||
<section ref={mediaContainerRef} className="relative h-screen landscape:flex-1">
|
<section
|
||||||
<div className="relative h-screen overflow-hidden">
|
ref={mediaContainerRef}
|
||||||
{isVideo ? (
|
className="relative isolate h-screen landscape:flex-1"
|
||||||
<VideoPlayer
|
>
|
||||||
ref={videoRef}
|
<div className="relative isolate h-screen overflow-hidden">
|
||||||
videoUrl={(data as VideoData).video_url}
|
<div className="absolute inset-0 z-0">
|
||||||
rotation={playerState.rotation}
|
{isVideo ? (
|
||||||
objectFit={playerState.objectFit}
|
<VideoPlayer
|
||||||
loop={playerState.loopMode === "loop"}
|
ref={videoRef}
|
||||||
onTogglePlay={togglePlay}
|
videoUrl={(data as VideoData).video_url}
|
||||||
/>
|
rotation={playerState.rotation}
|
||||||
) : (
|
objectFit={playerState.objectFit}
|
||||||
<ImageCarousel
|
loop={playerState.loopMode !== "sequential"}
|
||||||
ref={scrollerRef}
|
onTogglePlay={togglePlay}
|
||||||
images={images}
|
/>
|
||||||
currentIndex={imageCarouselState.idx}
|
) : (
|
||||||
onTogglePlay={togglePlay}
|
<ImageCarousel
|
||||||
/>
|
ref={scrollerRef}
|
||||||
)}
|
images={images}
|
||||||
|
currentIndex={imageCarouselState.idx}
|
||||||
|
isPlaying={playerState.isPlaying}
|
||||||
|
segmentProgress={imageCarouselState.segProgress}
|
||||||
|
mediaSyncToken={imageCarouselState.mediaSyncToken}
|
||||||
|
loopMode={playerState.loopMode}
|
||||||
|
onTogglePlay={togglePlay}
|
||||||
|
onAnimatedDuration={imageCarouselState.setSegmentDuration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 暂停图标 */}
|
{/* 暂停图标 */}
|
||||||
{!playerState.isPlaying && (
|
{!playerState.isPlaying && (
|
||||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
<div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
|
||||||
<div className="rounded-full bg-black/40 p-6 backdrop-blur-sm">
|
<div className="rounded-full bg-black/40 p-6 backdrop-blur-sm">
|
||||||
<Play size={64} className="text-white" fill="white" />
|
<Play size={64} className="text-white" fill="white" />
|
||||||
</div>
|
</div>
|
||||||
@ -496,7 +559,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
hasTranscript={isVideo && !!transcript?.speech_detected}
|
hasTranscript={isVideo && !!transcript?.speech_detected}
|
||||||
onShowTranscript={() => setTranscriptOpen(true)}
|
onShowTranscript={() => setTranscriptOpen(true)}
|
||||||
onTogglePlay={togglePlay}
|
onTogglePlay={togglePlay}
|
||||||
onSeek={seekTo}
|
onSeek={handleControlSeek}
|
||||||
onVolumeChange={playerState.setVolume}
|
onVolumeChange={playerState.setVolume}
|
||||||
onRateChange={playerState.setRate}
|
onRateChange={playerState.setRate}
|
||||||
onRotationChange={playerState.setRotation}
|
onRotationChange={playerState.setRotation}
|
||||||
@ -512,8 +575,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
|
|||||||
neighbors={neighbors}
|
neighbors={neighbors}
|
||||||
commentsCount={data.commentsCount}
|
commentsCount={data.commentsCount}
|
||||||
likesCount={data.likesCount}
|
likesCount={data.likesCount}
|
||||||
onNavigatePrev={() => neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)}
|
onNavigatePrev={() =>
|
||||||
onNavigateNext={() => neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)}
|
neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)
|
||||||
|
}
|
||||||
|
onNavigateNext={() =>
|
||||||
|
neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)
|
||||||
|
}
|
||||||
onToggleComments={() => commentState.setOpen((v) => !v)}
|
onToggleComments={() => commentState.setOpen((v) => !v)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { forwardRef } from "react";
|
|||||||
|
|
||||||
interface BackgroundCanvasProps {}
|
interface BackgroundCanvasProps {}
|
||||||
|
|
||||||
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
|
export const BackgroundCanvas = forwardRef<
|
||||||
|
HTMLCanvasElement,
|
||||||
|
BackgroundCanvasProps
|
||||||
|
>((props, ref) => {
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -18,12 +18,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
<header className="flex items-center gap-4 mb-5">
|
<header className="flex items-center gap-4 mb-5">
|
||||||
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
||||||
{author.avatar_url ? (
|
{author.avatar_url ? (
|
||||||
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<img
|
||||||
|
src={author.avatar_url}
|
||||||
|
alt="avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
|
<div className="font-medium text-white/95 text-sm sm:text-base">
|
||||||
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}>
|
{author.nickname}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-white/50"
|
||||||
|
title={formatAbsoluteUTC(createdAt)}
|
||||||
|
>
|
||||||
发布于 {formatRelativeTime(createdAt)}
|
发布于 {formatRelativeTime(createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -34,13 +43,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
|
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
|
||||||
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
|
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
|
||||||
{c.user.avatar_url ? (
|
{c.user.avatar_url ? (
|
||||||
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<img
|
||||||
|
src={c.user.avatar_url}
|
||||||
|
alt="avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
<span className="font-medium text-white/95 text-sm">
|
||||||
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span>
|
{c.user.nickname}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
{formatRelativeTime(c.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
|
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
|
||||||
<CommentText text={c.text} />
|
<CommentText text={c.text} />
|
||||||
@ -75,7 +92,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
|
{comments.length === 0 ? (
|
||||||
|
<li className="text-sm text-white/60">暂无评论</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* 图片预览灯箱 */}
|
{/* 图片预览灯箱 */}
|
||||||
|
|||||||
@ -12,13 +12,23 @@ interface CommentPanelProps {
|
|||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounted }: CommentPanelProps) {
|
export function CommentPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
author,
|
||||||
|
createdAt,
|
||||||
|
awemeId,
|
||||||
|
mounted,
|
||||||
|
}: CommentPanelProps) {
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
|
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
|
||||||
const [rankParams, setRankParams] = useState<null | { seed: string; snapshot: string }>(null);
|
const [rankParams, setRankParams] = useState<null | {
|
||||||
|
seed: string;
|
||||||
|
snapshot: string;
|
||||||
|
}>(null);
|
||||||
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
|
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
|
||||||
const scrollRefLandscape = useRef<HTMLDivElement>(null);
|
const scrollRefLandscape = useRef<HTMLDivElement>(null);
|
||||||
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
|
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
|
||||||
@ -27,67 +37,81 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
|
|||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
// 加载评论
|
// 加载评论
|
||||||
const loadComments = useCallback(async (reset = false) => {
|
const loadComments = useCallback(
|
||||||
if (loadingRef.current || (!reset && !hasMore)) return;
|
async (reset = false) => {
|
||||||
|
if (loadingRef.current || (!reset && !hasMore)) return;
|
||||||
loadingRef.current = true;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
loadingRef.current = true;
|
||||||
const skip = reset ? 0 : comments.length;
|
setLoading(true);
|
||||||
const query = new URLSearchParams({
|
|
||||||
skip: String(skip),
|
|
||||||
take: String(20),
|
|
||||||
mode: 'ranked',
|
|
||||||
});
|
|
||||||
if (rankParams) {
|
|
||||||
query.set('seed', rankParams.seed);
|
|
||||||
query.set('snapshot', rankParams.snapshot);
|
|
||||||
}
|
|
||||||
const response = await fetch(`/api/comments/${awemeId}?${query.toString()}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 统一做一次基于 cid 的去重,避免分页偶发重复
|
try {
|
||||||
if (reset) {
|
const skip = reset ? 0 : comments.length;
|
||||||
setComments(() => {
|
const query = new URLSearchParams({
|
||||||
const seen = new Set<string>();
|
skip: String(skip),
|
||||||
return (data.comments as Comment[]).filter((c) => {
|
take: String(20),
|
||||||
if (seen.has(c.cid)) return false;
|
mode: "ranked",
|
||||||
seen.add(c.cid);
|
});
|
||||||
return true;
|
if (rankParams) {
|
||||||
|
query.set("seed", rankParams.seed);
|
||||||
|
query.set("snapshot", rankParams.snapshot);
|
||||||
|
}
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/comments/${awemeId}?${query.toString()}`,
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 统一做一次基于 cid 的去重,避免分页偶发重复
|
||||||
|
if (reset) {
|
||||||
|
setComments(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (data.comments as Comment[]).filter((c) => {
|
||||||
|
if (seen.has(c.cid)) return false;
|
||||||
|
seen.add(c.cid);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
} else {
|
setComments((prev) => {
|
||||||
setComments((prev) => {
|
const merged = [...prev, ...(data.comments as Comment[])];
|
||||||
const merged = [...prev, ...(data.comments as Comment[])];
|
const seen = new Set<string>();
|
||||||
const seen = new Set<string>();
|
// 保留首次出现的项,既保证顺序也避免重复
|
||||||
// 保留首次出现的项,既保证顺序也避免重复
|
return merged.filter((c) => {
|
||||||
return merged.filter((c) => {
|
if (seen.has(c.cid)) return false;
|
||||||
if (seen.has(c.cid)) return false;
|
seen.add(c.cid);
|
||||||
seen.add(c.cid);
|
return true;
|
||||||
return true;
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
setTotal(data.total);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
if (data.mode === "ranked" && data.seed && data.snapshot) {
|
||||||
|
// 初始化或重置时更新稳定参数
|
||||||
|
setRankParams((prev) => {
|
||||||
|
if (reset)
|
||||||
|
return {
|
||||||
|
seed: String(data.seed),
|
||||||
|
snapshot: String(data.snapshot),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
prev ?? {
|
||||||
|
seed: String(data.seed),
|
||||||
|
snapshot: String(data.snapshot),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if (reset) {
|
||||||
|
setRankParams(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载评论失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
setTotal(data.total);
|
[awemeId, comments.length, hasMore],
|
||||||
setHasMore(data.hasMore);
|
);
|
||||||
if (data.mode === 'ranked' && data.seed && data.snapshot) {
|
|
||||||
// 初始化或重置时更新稳定参数
|
|
||||||
setRankParams((prev) => {
|
|
||||||
if (reset) return { seed: String(data.seed), snapshot: String(data.snapshot) };
|
|
||||||
return prev ?? { seed: String(data.seed), snapshot: String(data.snapshot) };
|
|
||||||
});
|
|
||||||
} else if (reset) {
|
|
||||||
setRankParams(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载评论失败:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
loadingRef.current = false;
|
|
||||||
}
|
|
||||||
}, [awemeId, comments.length, hasMore]);
|
|
||||||
|
|
||||||
// 面板打开时加载初始评论
|
// 面板打开时加载初始评论
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -102,7 +126,10 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
|
|||||||
|
|
||||||
const observers: IntersectionObserver[] = [];
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => {
|
const setup = (
|
||||||
|
rootEl: HTMLDivElement | null,
|
||||||
|
targetEl: HTMLDivElement | null,
|
||||||
|
) => {
|
||||||
if (!rootEl || !targetEl) return;
|
if (!rootEl || !targetEl) return;
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@ -113,9 +140,9 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: rootEl,
|
root: rootEl,
|
||||||
rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发
|
rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
io.observe(targetEl);
|
io.observe(targetEl);
|
||||||
observers.push(io);
|
observers.push(io);
|
||||||
@ -180,19 +207,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRefLandscape} className="p-3 overflow-auto comment-scroll">
|
<div
|
||||||
<CommentList author={author} createdAt={createdAt} comments={comments} />
|
ref={scrollRefLandscape}
|
||||||
|
className="p-3 overflow-auto comment-scroll"
|
||||||
|
>
|
||||||
|
<CommentList
|
||||||
|
author={author}
|
||||||
|
createdAt={createdAt}
|
||||||
|
comments={comments}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 底部加载触发区 */}
|
{/* 底部加载触发区 */}
|
||||||
{hasMore && (
|
{hasMore && <div ref={sentinelRefLandscape} className="h-1 w-full" />}
|
||||||
<div ref={sentinelRefLandscape} className="h-1 w-full" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
|
<div className="py-4 text-center text-white/60 text-sm">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMore && comments.length > 0 && (
|
{!hasMore && comments.length > 0 && (
|
||||||
<div className="py-4 text-center text-white/40 text-sm">没有更多评论了</div>
|
<div className="py-4 text-center text-white/40 text-sm">
|
||||||
|
没有更多评论了
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -221,19 +257,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRefPortrait} className="p-3 overflow-auto comment-scroll">
|
<div
|
||||||
<CommentList author={author} createdAt={createdAt} comments={comments} />
|
ref={scrollRefPortrait}
|
||||||
|
className="p-3 overflow-auto comment-scroll"
|
||||||
|
>
|
||||||
|
<CommentList
|
||||||
|
author={author}
|
||||||
|
createdAt={createdAt}
|
||||||
|
comments={comments}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 底部加载触发区 */}
|
{/* 底部加载触发区 */}
|
||||||
{hasMore && (
|
{hasMore && <div ref={sentinelRefPortrait} className="h-1 w-full" />}
|
||||||
<div ref={sentinelRefPortrait} className="h-1 w-full" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
|
<div className="py-4 text-center text-white/60 text-sm">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMore && comments.length > 0 && (
|
{!hasMore && comments.length > 0 && (
|
||||||
<div className="py-4 text-center text-white/40 text-sm">没有更多评论了</div>
|
<div className="py-4 text-center text-white/40 text-sm">
|
||||||
|
没有更多评论了
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
|
|||||||
// 如果图片加载失败,显示原始文本
|
// 如果图片加载失败,显示原始文本
|
||||||
e.currentTarget.style.display = "none";
|
e.currentTarget.style.display = "none";
|
||||||
const textNode = document.createTextNode(`[${part.name}]`);
|
const textNode = document.createTextNode(`[${part.name}]`);
|
||||||
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
|
e.currentTarget.parentNode?.insertBefore(
|
||||||
|
textNode,
|
||||||
|
e.currentTarget,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,78 +1,134 @@
|
|||||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
import {
|
||||||
import type { ImageData } from "../types.ts";
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type { ImageData, LoopMode } from "../types.ts";
|
||||||
|
|
||||||
interface ImageCarouselProps {
|
interface ImageCarouselProps {
|
||||||
images: ImageData["images"];
|
images: ImageData["images"];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
segmentProgress: number;
|
||||||
|
mediaSyncToken: number;
|
||||||
|
loopMode: LoopMode;
|
||||||
onTogglePlay: () => void;
|
onTogglePlay: () => void;
|
||||||
|
onAnimatedDuration?: (imageId: string, durationMs: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
||||||
({ images, currentIndex, onTogglePlay }, ref) => {
|
(
|
||||||
const [offset, setOffset] = useState(0);
|
{
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
images,
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
currentIndex,
|
||||||
|
isPlaying,
|
||||||
|
segmentProgress,
|
||||||
|
mediaSyncToken,
|
||||||
|
loopMode,
|
||||||
|
onTogglePlay,
|
||||||
|
onAnimatedDuration,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
||||||
const playedVideos = useRef<Set<string>>(new Set());
|
const activeVideoIdRef = useRef<string | null>(null);
|
||||||
|
const segmentProgressRef = useRef(segmentProgress);
|
||||||
|
const [useLightweightMode, setUseLightweightMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
segmentProgressRef.current = segmentProgress;
|
||||||
|
}, [segmentProgress]);
|
||||||
|
|
||||||
|
const syncVideoToSegmentProgress = useCallback(
|
||||||
|
(videoEl: HTMLVideoElement, progress: number, force = false) => {
|
||||||
|
const duration = videoEl.duration;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||||
|
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
||||||
|
const maxTime = Math.max(0, duration - 0.05);
|
||||||
|
const targetTime = Math.min(maxTime, duration * clampedProgress);
|
||||||
|
|
||||||
|
if (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) {
|
||||||
|
try {
|
||||||
|
videoEl.currentTime = targetTime;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const isIOS =
|
||||||
|
/iPad|iPhone|iPod/.test(userAgent) ||
|
||||||
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
const isSafari =
|
||||||
|
/Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent);
|
||||||
|
setUseLightweightMode(isIOS && isSafari);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 虚拟滚动:只渲染当前图片和前后各一张
|
// 虚拟滚动:只渲染当前图片和前后各一张
|
||||||
const visibleIndices = (() => {
|
const visibleIndices = useMemo(() => {
|
||||||
|
if (useLightweightMode) return [currentIndex];
|
||||||
|
|
||||||
const indices: number[] = [];
|
const indices: number[] = [];
|
||||||
if (currentIndex > 0) indices.push(currentIndex - 1);
|
if (currentIndex > 0) indices.push(currentIndex - 1);
|
||||||
indices.push(currentIndex);
|
indices.push(currentIndex);
|
||||||
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
||||||
return indices;
|
return indices;
|
||||||
})();
|
}, [currentIndex, images.length, useLightweightMode]);
|
||||||
|
|
||||||
// 当 currentIndex 变化时,触发滚动动画
|
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsTransitioning(true);
|
const currentImage = images[currentIndex];
|
||||||
setOffset(-currentIndex * 100);
|
if (!currentImage?.animated) {
|
||||||
|
activeVideoIdRef.current = null;
|
||||||
|
videoRefs.current.forEach((videoEl) => videoEl.pause());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const videoEl = videoRefs.current.get(currentImage.id);
|
||||||
setIsTransitioning(false);
|
|
||||||
}, 300); // 与 CSS transition 时间匹配
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
videoRefs.current.forEach((itemVideoEl, imageId) => {
|
||||||
}, [currentIndex]);
|
if (imageId !== currentImage.id || !isPlaying) {
|
||||||
|
itemVideoEl.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoEl) {
|
||||||
|
if (activeVideoIdRef.current !== currentImage.id) {
|
||||||
|
activeVideoIdRef.current = currentImage.id;
|
||||||
|
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
videoEl.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
videoEl.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
|
||||||
|
|
||||||
// 管理动图播放:进入视口时播放一次
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentImage = images[currentIndex];
|
const currentImage = images[currentIndex];
|
||||||
if (!currentImage?.animated) return;
|
if (!currentImage?.animated) return;
|
||||||
|
|
||||||
const videoKey = currentImage.id;
|
const videoEl = videoRefs.current.get(currentImage.id);
|
||||||
const videoEl = videoRefs.current.get(videoKey);
|
if (!videoEl) return;
|
||||||
|
|
||||||
if (videoEl) {
|
|
||||||
// 检查是否已经播放过
|
|
||||||
if (!playedVideos.current.has(videoKey)) {
|
|
||||||
// 重置并播放
|
|
||||||
videoEl.currentTime = 0;
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
playedVideos.current.add(videoKey);
|
|
||||||
} else {
|
|
||||||
// 已播放过,重置到开头但不自动播放
|
|
||||||
videoEl.currentTime = 0;
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentIndex, images]);
|
|
||||||
|
|
||||||
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
|
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
|
||||||
useEffect(() => {
|
if (isPlaying) videoEl.play().catch(() => {});
|
||||||
const currentImage = images[currentIndex];
|
}, [
|
||||||
if (currentImage?.animated) {
|
currentIndex,
|
||||||
const videoKey = currentImage.id;
|
images,
|
||||||
|
isPlaying,
|
||||||
// 清除其他视频的播放记录
|
loopMode,
|
||||||
playedVideos.current.forEach(key => {
|
mediaSyncToken,
|
||||||
if (key !== videoKey) {
|
syncVideoToSegmentProgress,
|
||||||
playedVideos.current.delete(key);
|
]);
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [currentIndex, images]);
|
|
||||||
|
|
||||||
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
@ -83,49 +139,81 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} className="relative h-full w-full overflow-hidden">
|
||||||
ref={ref}
|
<div className="relative h-full w-full">
|
||||||
className="relative h-full w-full overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="flex h-full w-full"
|
|
||||||
style={{
|
|
||||||
transform: `translateX(${offset}%)`,
|
|
||||||
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleIndices.map((i) => {
|
{visibleIndices.map((i) => {
|
||||||
const img = images[i];
|
const img = images[i];
|
||||||
|
const isCurrent = i === currentIndex;
|
||||||
|
const animatedSrc = isCurrent ? img.animated : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
data-active={isCurrent ? "true" : undefined}
|
||||||
|
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${i * 100}%)`,
|
transform: useLightweightMode
|
||||||
position: 'absolute',
|
? "translateX(0)"
|
||||||
left: 0,
|
: `translateX(${(i - currentIndex) * 100}%)`,
|
||||||
top: 0,
|
transition: useLightweightMode
|
||||||
width: '100%',
|
? "none"
|
||||||
|
: "transform 240ms ease-out",
|
||||||
|
zIndex: isCurrent ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
>
|
>
|
||||||
{img.animated ? (
|
{animatedSrc ? (
|
||||||
<video
|
<video
|
||||||
ref={(el) => handleVideoRef(el, img.id)}
|
ref={(el) => handleVideoRef(el, img.id)}
|
||||||
src={img.animated}
|
src={animatedSrc}
|
||||||
|
poster={img.url}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
|
loop={loopMode === "single"}
|
||||||
|
preload={useLightweightMode ? "none" : "metadata"}
|
||||||
|
controls={false}
|
||||||
|
disablePictureInPicture
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
|
style={{
|
||||||
|
width: img.width ? `${img.width}px` : undefined,
|
||||||
|
height: img.height ? `${img.height}px` : undefined,
|
||||||
|
}}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
const duration = e.currentTarget.duration;
|
||||||
|
if (Number.isFinite(duration) && duration > 0) {
|
||||||
|
onAnimatedDuration?.(
|
||||||
|
img.id,
|
||||||
|
Math.round(duration * 1000),
|
||||||
|
);
|
||||||
|
if (isCurrent) {
|
||||||
|
syncVideoToSegmentProgress(
|
||||||
|
e.currentTarget,
|
||||||
|
segmentProgressRef.current,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCanPlay={(e) => {
|
||||||
|
if (isCurrent) {
|
||||||
|
syncVideoToSegmentProgress(
|
||||||
|
e.currentTarget,
|
||||||
|
segmentProgressRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isCurrent && isPlaying)
|
||||||
|
e.currentTarget.play().catch(() => {});
|
||||||
|
}}
|
||||||
onEnded={(e) => {
|
onEnded={(e) => {
|
||||||
// 播放结束后停留在最后一帧
|
// 播放结束后停留在最后一帧
|
||||||
e.currentTarget.pause();
|
if (loopMode !== "single") e.currentTarget.pause();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={`image-${i + 1}`}
|
alt={`image-${i + 1}`}
|
||||||
|
loading={isCurrent ? "eager" : "lazy"}
|
||||||
|
decoding="async"
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
style={{
|
style={{
|
||||||
width: img.width ? `${img.width}px` : undefined,
|
width: img.width ? `${img.width}px` : undefined,
|
||||||
@ -139,7 +227,7 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ImageCarousel.displayName = "ImageCarousel";
|
ImageCarousel.displayName = "ImageCarousel";
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
Repeat,
|
||||||
Repeat1,
|
Repeat1,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
@ -89,25 +90,70 @@ export function MediaControls({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
}: MediaControlsProps) {
|
}: MediaControlsProps) {
|
||||||
|
const normalizedLoopMode =
|
||||||
|
isVideo && loopMode === "single" ? "loop" : loopMode;
|
||||||
|
const loopLabel = isVideo
|
||||||
|
? normalizedLoopMode === "loop"
|
||||||
|
? "循环播放"
|
||||||
|
: "顺序播放"
|
||||||
|
: loopMode === "single"
|
||||||
|
? "单页循环"
|
||||||
|
: loopMode === "loop"
|
||||||
|
? "图文循环"
|
||||||
|
: "顺序播放";
|
||||||
|
const handleLoopModeToggle = () => {
|
||||||
|
if (isVideo) {
|
||||||
|
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoopModeChange(
|
||||||
|
loopMode === "loop"
|
||||||
|
? "single"
|
||||||
|
: loopMode === "single"
|
||||||
|
? "sequential"
|
||||||
|
: "loop",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const renderLoopIcon = () => {
|
||||||
|
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
|
||||||
|
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
|
||||||
|
return <ArrowDownUp size={18} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
|
<div className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
|
||||||
{/* 描述行 */}
|
{/* 描述行 */}
|
||||||
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
|
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
|
||||||
{author.sec_uid ? (
|
{author.sec_uid ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/author/${author.sec_uid}`}
|
href={`/author/${author.sec_uid}`}
|
||||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
<img
|
||||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">{author.nickname}</span>
|
src={author.avatar_url!}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
|
||||||
|
{author.nickname}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
<img
|
||||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
|
src={author.avatar_url!}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
|
||||||
|
{author.nickname}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
||||||
title={formatAbsoluteUTC(createdAt)}
|
title={formatAbsoluteUTC(createdAt)}
|
||||||
@ -150,16 +196,16 @@ export function MediaControls({
|
|||||||
|
|
||||||
{/* 播放进度显示 - 所有设备都显示 */}
|
{/* 播放进度显示 - 所有设备都显示 */}
|
||||||
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
|
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
|
||||||
{isVideo ? (
|
{isVideo
|
||||||
(() => {
|
? (() => {
|
||||||
const v = videoRef?.current;
|
const v = videoRef?.current;
|
||||||
const current = v?.currentTime ?? 0;
|
const current = v?.currentTime ?? 0;
|
||||||
const total = v?.duration ?? 0;
|
const total = v?.duration ?? 0;
|
||||||
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--";
|
return total > 0
|
||||||
})()
|
? `${formatTime(current)} / ${formatTime(total)}`
|
||||||
) : (
|
: "--:-- / --:--";
|
||||||
`${currentIndex + 1} / ${totalSegments}`
|
})()
|
||||||
)}
|
: `${currentIndex + 1} / ${totalSegments}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
|
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
|
||||||
@ -231,21 +277,31 @@ export function MediaControls({
|
|||||||
{/* 循环模式 - 中等屏幕以上显示 */}
|
{/* 循环模式 - 中等屏幕以上显示 */}
|
||||||
<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"
|
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={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
|
onClick={handleLoopModeToggle}
|
||||||
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
aria-label={loopLabel}
|
||||||
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
title={loopLabel}
|
||||||
>
|
>
|
||||||
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
|
{renderLoopIcon()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 适配模式 - 小屏幕以上显示 */}
|
{/* 适配模式 - 小屏幕以上显示 */}
|
||||||
<button
|
<button
|
||||||
className="hidden sm: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"
|
className="hidden sm: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={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
|
onClick={() =>
|
||||||
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
|
||||||
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
}
|
||||||
|
aria-label={
|
||||||
|
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
{objectFit === "contain" ? (
|
||||||
|
<Maximize2 size={18} />
|
||||||
|
) : (
|
||||||
|
<Minimize size={18} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
||||||
@ -277,18 +333,28 @@ export function MediaControls({
|
|||||||
{/* 小屏幕隐藏的适配模式 */}
|
{/* 小屏幕隐藏的适配模式 */}
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
icon={
|
||||||
|
objectFit === "contain" ? (
|
||||||
|
<Maximize2 size={18} />
|
||||||
|
) : (
|
||||||
|
<Minimize size={18} />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
||||||
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
|
onClick={() =>
|
||||||
|
onObjectFitChange(
|
||||||
|
objectFit === "contain" ? "cover" : "contain",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 中等屏幕以下隐藏的循环模式 */}
|
{/* 中等屏幕以下隐藏的循环模式 */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
|
icon={renderLoopIcon()}
|
||||||
label={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
label={loopLabel}
|
||||||
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
|
onClick={handleLoopModeToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -338,13 +404,13 @@ export function MediaControls({
|
|||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||||
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
|
{!isVideo && musicUrl ? (
|
||||||
|
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,9 +51,7 @@ export function MoreMenu({ children }: MoreMenuProps) {
|
|||||||
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"
|
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" }}
|
style={{ minWidth: "200px" }}
|
||||||
>
|
>
|
||||||
<div className="p-2 flex flex-col gap-1">
|
<div className="p-2 flex flex-col gap-1">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
MessageSquareText,
|
||||||
|
ThumbsUp,
|
||||||
|
} from "lucide-react";
|
||||||
import type { Neighbors } from "../types.ts";
|
import type { Neighbors } from "../types.ts";
|
||||||
|
|
||||||
interface NavigationButtonsProps {
|
interface NavigationButtonsProps {
|
||||||
@ -21,14 +26,13 @@ export function NavigationButtons({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
|
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
|
||||||
<button
|
<button className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2">
|
||||||
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
|
|
||||||
>
|
|
||||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||||
<ThumbsUp size={40} className="" />
|
<ThumbsUp size={40} className="" />
|
||||||
|
|
||||||
<span className="text-[16px] font-semibold text-white/90 drop-shadow">{likesCount}</span>
|
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
|
||||||
|
{likesCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/* 评论开关(右侧中部) */}
|
{/* 评论开关(右侧中部) */}
|
||||||
@ -40,13 +44,17 @@ export function NavigationButtons({
|
|||||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||||
<MessageSquareText size={40} className="" />
|
<MessageSquareText size={40} className="" />
|
||||||
|
|
||||||
{commentsCount > 0 ? <span className="text-[16px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
|
{commentsCount > 0 ? (
|
||||||
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span>
|
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
|
||||||
}
|
{commentsCount}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] font-semibold text-white/90 drop-shadow">
|
||||||
|
暂无评论
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 上下切换按钮(右侧胶囊形状) */}
|
{/* 上下切换按钮(右侧胶囊形状) */}
|
||||||
|
|||||||
@ -4,15 +4,41 @@ interface ProgressBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
|
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
|
||||||
|
const seekFromClientX = (clientX: number, element: HTMLElement) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
onSeek((clientX - rect.left) / rect.width);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer touch-none"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
||||||
onSeek((e.clientX - rect.left) / rect.width);
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
seekFromClientX(e.clientX, e.currentTarget);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
||||||
|
seekFromClientX(e.clientX, e.currentTarget);
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
|
<div
|
||||||
|
className="origin-left h-full bg-white"
|
||||||
|
style={{ transform: `scaleX(${progress || 0})` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,35 @@ export function SegmentedProgressBar({
|
|||||||
segmentProgress,
|
segmentProgress,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: SegmentedProgressBarProps) {
|
}: SegmentedProgressBarProps) {
|
||||||
|
const seekFromClientX = (clientX: number, element: HTMLElement) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
onSeek((clientX - rect.left) / rect.width);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-1.5 cursor-pointer"
|
className="relative h-1.5 cursor-pointer touch-none"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
||||||
onSeek((e.clientX - rect.left) / rect.width);
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
seekFromClientX(e.clientX, e.currentTarget);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
||||||
|
seekFromClientX(e.clientX, e.currentTarget);
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-1.5 h-full">
|
<div className="flex gap-1.5 h-full">
|
||||||
@ -30,7 +53,12 @@ export function SegmentedProgressBar({
|
|||||||
aria-label={`第 ${i + 1} 段`}
|
aria-label={`第 ${i + 1} 段`}
|
||||||
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} />
|
<div
|
||||||
|
className="h-full origin-left bg-white"
|
||||||
|
style={{
|
||||||
|
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -10,7 +10,11 @@ interface TranscriptPanelProps {
|
|||||||
transcript: VideoTranscript | null;
|
transcript: VideoTranscript | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) {
|
export function TranscriptPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
transcript,
|
||||||
|
}: TranscriptPanelProps) {
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
const [copiedAll, setCopiedAll] = useState(false);
|
const [copiedAll, setCopiedAll] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
|||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
VideoPlayer.displayName = "VideoPlayer";
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|||||||
@ -1,3 +1,216 @@
|
|||||||
export const emojiList = [
|
export const emojiList = [
|
||||||
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
|
"微笑",
|
||||||
]
|
"色",
|
||||||
|
"发呆",
|
||||||
|
"酷拽",
|
||||||
|
"抠鼻",
|
||||||
|
"流泪",
|
||||||
|
"捂脸",
|
||||||
|
"发怒",
|
||||||
|
"呲牙",
|
||||||
|
"尬笑",
|
||||||
|
"害羞",
|
||||||
|
"调皮",
|
||||||
|
"舔屏",
|
||||||
|
"看",
|
||||||
|
"爱心",
|
||||||
|
"比心",
|
||||||
|
"赞",
|
||||||
|
"鼓掌",
|
||||||
|
"感谢",
|
||||||
|
"抱抱你",
|
||||||
|
"玫瑰",
|
||||||
|
"尴尬流汗",
|
||||||
|
"戳手手",
|
||||||
|
"星星眼",
|
||||||
|
"杀马特",
|
||||||
|
"黄脸干杯",
|
||||||
|
"抱紧自己",
|
||||||
|
"拜拜",
|
||||||
|
"热化了",
|
||||||
|
"黄脸祈祷",
|
||||||
|
"懵",
|
||||||
|
"举手",
|
||||||
|
"加功德",
|
||||||
|
"摊手",
|
||||||
|
"无语流汗",
|
||||||
|
"续火花吧",
|
||||||
|
"点火",
|
||||||
|
"哭哭",
|
||||||
|
"吐舌小狗",
|
||||||
|
"送花",
|
||||||
|
"爱心手",
|
||||||
|
"贴贴",
|
||||||
|
"灵机一动",
|
||||||
|
"耶",
|
||||||
|
"打脸",
|
||||||
|
"大笑",
|
||||||
|
"机智",
|
||||||
|
"送心",
|
||||||
|
"666",
|
||||||
|
"闭嘴",
|
||||||
|
"来看我",
|
||||||
|
"一起加油",
|
||||||
|
"哈欠",
|
||||||
|
"震惊",
|
||||||
|
"晕",
|
||||||
|
"衰",
|
||||||
|
"困",
|
||||||
|
"疑问",
|
||||||
|
"泣不成声",
|
||||||
|
"小鼓掌",
|
||||||
|
"大金牙",
|
||||||
|
"偷笑",
|
||||||
|
"石化",
|
||||||
|
"思考",
|
||||||
|
"吐血",
|
||||||
|
"可怜",
|
||||||
|
"嘘",
|
||||||
|
"撇嘴",
|
||||||
|
"笑哭",
|
||||||
|
"奸笑",
|
||||||
|
"得意",
|
||||||
|
"憨笑",
|
||||||
|
"坏笑",
|
||||||
|
"抓狂",
|
||||||
|
"泪奔",
|
||||||
|
"钱",
|
||||||
|
"恐惧",
|
||||||
|
"愉快",
|
||||||
|
"快哭了",
|
||||||
|
"翻白眼",
|
||||||
|
"互粉",
|
||||||
|
"我想静静",
|
||||||
|
"委屈",
|
||||||
|
"鄙视",
|
||||||
|
"飞吻",
|
||||||
|
"再见",
|
||||||
|
"紫薇别走",
|
||||||
|
"听歌",
|
||||||
|
"求抱抱",
|
||||||
|
"绝望的凝视",
|
||||||
|
"不失礼貌的微笑",
|
||||||
|
"不看",
|
||||||
|
"裂开",
|
||||||
|
"干饭人",
|
||||||
|
"庆祝",
|
||||||
|
"吐舌",
|
||||||
|
"呆无辜",
|
||||||
|
"白眼",
|
||||||
|
"猪头",
|
||||||
|
"冷漠",
|
||||||
|
"暗中观察",
|
||||||
|
"二哈",
|
||||||
|
"菜狗",
|
||||||
|
"黑脸",
|
||||||
|
"展开说说",
|
||||||
|
"蜜蜂狗",
|
||||||
|
"柴犬",
|
||||||
|
"摸头",
|
||||||
|
"皱眉",
|
||||||
|
"擦汗",
|
||||||
|
"红脸",
|
||||||
|
"做鬼脸",
|
||||||
|
"强",
|
||||||
|
"如花",
|
||||||
|
"吐",
|
||||||
|
"惊喜",
|
||||||
|
"敲打",
|
||||||
|
"奋斗",
|
||||||
|
"吐彩虹",
|
||||||
|
"大哭",
|
||||||
|
"嘿哈",
|
||||||
|
"惊恐",
|
||||||
|
"囧",
|
||||||
|
"难过",
|
||||||
|
"斜眼",
|
||||||
|
"阴险",
|
||||||
|
"悠闲",
|
||||||
|
"咒骂",
|
||||||
|
"吃瓜群众",
|
||||||
|
"绿帽子",
|
||||||
|
"敢怒不敢言",
|
||||||
|
"求求了",
|
||||||
|
"眼含热泪",
|
||||||
|
"叹气",
|
||||||
|
"好开心",
|
||||||
|
"不是吧",
|
||||||
|
"鞠躬",
|
||||||
|
"躺平",
|
||||||
|
"九转大肠",
|
||||||
|
"不你不想",
|
||||||
|
"一头乱麻",
|
||||||
|
"kisskiss",
|
||||||
|
"你不大行",
|
||||||
|
"噢买尬",
|
||||||
|
"宕机",
|
||||||
|
"苦涩",
|
||||||
|
"逞强落泪",
|
||||||
|
"求机位-黄脸",
|
||||||
|
"求机位3",
|
||||||
|
"点赞",
|
||||||
|
"精选",
|
||||||
|
"强壮",
|
||||||
|
"碰拳",
|
||||||
|
"OK",
|
||||||
|
"击掌",
|
||||||
|
"左上",
|
||||||
|
"握手",
|
||||||
|
"抱拳",
|
||||||
|
"勾引",
|
||||||
|
"拳头",
|
||||||
|
"弱",
|
||||||
|
"胜利",
|
||||||
|
"右边",
|
||||||
|
"左边",
|
||||||
|
"嘴唇",
|
||||||
|
"心碎",
|
||||||
|
"凋谢",
|
||||||
|
"愤怒",
|
||||||
|
"垃圾",
|
||||||
|
"啤酒",
|
||||||
|
"咖啡",
|
||||||
|
"蛋糕",
|
||||||
|
"礼物",
|
||||||
|
"撒花",
|
||||||
|
"加一",
|
||||||
|
"减一",
|
||||||
|
"okk",
|
||||||
|
"V5",
|
||||||
|
"绝",
|
||||||
|
"给力",
|
||||||
|
"红包",
|
||||||
|
"屎",
|
||||||
|
"发",
|
||||||
|
"18禁",
|
||||||
|
"炸弹",
|
||||||
|
"西瓜",
|
||||||
|
"加鸡腿",
|
||||||
|
"握爪",
|
||||||
|
"太阳",
|
||||||
|
"月亮",
|
||||||
|
"给跪了",
|
||||||
|
"蕉绿",
|
||||||
|
"扎心",
|
||||||
|
"胡瓜",
|
||||||
|
"打call",
|
||||||
|
"栓Q",
|
||||||
|
"雪花",
|
||||||
|
"圣诞树",
|
||||||
|
"平安果",
|
||||||
|
"圣诞帽",
|
||||||
|
"气球",
|
||||||
|
"烟花",
|
||||||
|
"福",
|
||||||
|
"candy",
|
||||||
|
"糖葫芦",
|
||||||
|
"鞭炮",
|
||||||
|
"元宝",
|
||||||
|
"灯笼",
|
||||||
|
"锦鲤",
|
||||||
|
"巧克力",
|
||||||
|
"戒指",
|
||||||
|
"棒棒糖",
|
||||||
|
"纸飞机",
|
||||||
|
"粽子",
|
||||||
|
];
|
||||||
|
|||||||
@ -23,6 +23,13 @@ export function useBackgroundCanvas({
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (!isVideo) {
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 1;
|
||||||
|
ctx.clearRect(0, 0, 1, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updateCanvasSize = () => {
|
const updateCanvasSize = () => {
|
||||||
canvas.width = Math.floor(window.innerWidth / 10);
|
canvas.width = Math.floor(window.innerWidth / 10);
|
||||||
canvas.height = Math.floor(window.innerHeight / 10);
|
canvas.height = Math.floor(window.innerHeight / 10);
|
||||||
@ -39,40 +46,29 @@ export function useBackgroundCanvas({
|
|||||||
const drawMediaToCanvas = () => {
|
const drawMediaToCanvas = () => {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
|
const sourceElement = videoRef.current;
|
||||||
|
|
||||||
if (isVideo) {
|
if (
|
||||||
sourceElement = videoRef.current;
|
!sourceElement ||
|
||||||
} else {
|
(sourceElement instanceof HTMLVideoElement &&
|
||||||
const scroller = scrollerRef.current;
|
sourceElement.readyState < 2)
|
||||||
if (scroller) {
|
)
|
||||||
// 虚拟滚动:查找所有图片容器,找到当前显示的那个
|
return;
|
||||||
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
|
|
||||||
for (const container of containers) {
|
|
||||||
const img = container.querySelector("img, video");
|
|
||||||
if (img && container.style.transform.includes(`${idx * 100}%`)) {
|
|
||||||
sourceElement = img as HTMLImageElement | HTMLVideoElement;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
|
|
||||||
|
|
||||||
const canvasWidth = canvas.width;
|
const canvasWidth = canvas.width;
|
||||||
const canvasHeight = canvas.height;
|
const canvasHeight = canvas.height;
|
||||||
const sourceWidth =
|
const sourceWidth = sourceElement.videoWidth;
|
||||||
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
|
const sourceHeight = sourceElement.videoHeight;
|
||||||
const sourceHeight =
|
|
||||||
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
|
|
||||||
|
|
||||||
if (!sourceWidth || !sourceHeight) return;
|
if (!sourceWidth || !sourceHeight) return;
|
||||||
|
|
||||||
const canvasRatio = canvasWidth / canvasHeight;
|
const canvasRatio = canvasWidth / canvasHeight;
|
||||||
const sourceRatio = sourceWidth / sourceHeight;
|
const sourceRatio = sourceWidth / sourceHeight;
|
||||||
|
|
||||||
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
|
let drawWidth: number,
|
||||||
|
drawHeight: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number;
|
||||||
|
|
||||||
if (canvasRatio > sourceRatio) {
|
if (canvasRatio > sourceRatio) {
|
||||||
drawWidth = canvasWidth;
|
drawWidth = canvasWidth;
|
||||||
|
|||||||
@ -1,8 +1,32 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
|
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
|
||||||
|
|
||||||
|
const MIN_SEGMENT_MS = 250;
|
||||||
|
const UI_UPDATE_INTERVAL_MS = 33;
|
||||||
|
const PROGRESS_EPSILON = 0.0001;
|
||||||
|
|
||||||
|
function normalizeDuration(
|
||||||
|
duration: number | null | undefined,
|
||||||
|
fallback: number,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof duration !== "number" ||
|
||||||
|
!Number.isFinite(duration) ||
|
||||||
|
duration <= 0
|
||||||
|
) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumDurations(durations: number[], end: number) {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseImageCarouselProps {
|
interface UseImageCarouselProps {
|
||||||
images: ImageData["images"];
|
images: ImageData["images"];
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@ -10,7 +34,6 @@ interface UseImageCarouselProps {
|
|||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
volume: number;
|
volume: number;
|
||||||
audioRef: RefObject<HTMLAudioElement | null>;
|
audioRef: RefObject<HTMLAudioElement | null>;
|
||||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
setProgress: (progress: number) => void;
|
setProgress: (progress: number) => void;
|
||||||
/** 单张图片显示时长(毫秒),默认 5000ms */
|
/** 单张图片显示时长(毫秒),默认 5000ms */
|
||||||
segmentMs?: number;
|
segmentMs?: number;
|
||||||
@ -23,22 +46,186 @@ export function useImageCarousel({
|
|||||||
neighbors,
|
neighbors,
|
||||||
volume,
|
volume,
|
||||||
audioRef,
|
audioRef,
|
||||||
scrollerRef,
|
|
||||||
setProgress,
|
setProgress,
|
||||||
segmentMs = 5000,
|
segmentMs = 5000,
|
||||||
}: UseImageCarouselProps) {
|
}: UseImageCarouselProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [idx, setIdx] = useState(0);
|
const [idx, setIdx] = useState(0);
|
||||||
const [segProgress, setSegProgress] = useState(0);
|
const [segProgress, setSegProgress] = useState(0);
|
||||||
|
const [durationOverrides, setDurationOverrides] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
|
const [mediaSyncToken, setMediaSyncToken] = useState(0);
|
||||||
|
|
||||||
const segStartRef = useRef<number | null>(null);
|
const segStartRef = useRef<number | null>(null);
|
||||||
const idxRef = useRef<number>(0);
|
const idxRef = useRef<number>(0);
|
||||||
const rafRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const lastUiUpdateRef = useRef(0);
|
||||||
|
const segProgressRef = useRef(0);
|
||||||
|
const totalProgressRef = useRef(0);
|
||||||
|
|
||||||
|
const fallbackDuration = normalizeDuration(segmentMs, 5000);
|
||||||
|
const segmentDurations = useMemo(
|
||||||
|
() =>
|
||||||
|
images.map((img) =>
|
||||||
|
normalizeDuration(
|
||||||
|
durationOverrides[img.id] ?? img.duration,
|
||||||
|
fallbackDuration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[durationOverrides, fallbackDuration, images],
|
||||||
|
);
|
||||||
|
const imageKey = useMemo(
|
||||||
|
() => images.map((img) => img.id).join("|"),
|
||||||
|
[images],
|
||||||
|
);
|
||||||
|
const totalDurationMs = useMemo(
|
||||||
|
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
|
||||||
|
[segmentDurations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncProgress = useCallback(
|
||||||
|
(index: number, segmentProgress: number, force = false) => {
|
||||||
|
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
|
||||||
|
const completedMs = sumDurations(segmentDurations, index);
|
||||||
|
const totalProgress =
|
||||||
|
totalDurationMs > 0
|
||||||
|
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
|
||||||
|
: 0;
|
||||||
|
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||||
|
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
|
||||||
|
|
||||||
|
if (
|
||||||
|
force ||
|
||||||
|
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
|
||||||
|
) {
|
||||||
|
segProgressRef.current = nextSegProgress;
|
||||||
|
setSegProgress(nextSegProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
force ||
|
||||||
|
Math.abs(nextTotalProgress - totalProgressRef.current) >
|
||||||
|
PROGRESS_EPSILON
|
||||||
|
) {
|
||||||
|
totalProgressRef.current = nextTotalProgress;
|
||||||
|
setProgress(nextTotalProgress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSegmentDuration = useCallback(
|
||||||
|
(imageId: string, durationMs: number) => {
|
||||||
|
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
|
||||||
|
const normalizedDuration = normalizeDuration(
|
||||||
|
durationMs,
|
||||||
|
fallbackDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
setDurationOverrides((current) => {
|
||||||
|
if (current[imageId] === normalizedDuration) return current;
|
||||||
|
return { ...current, [imageId]: normalizedDuration };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[fallbackDuration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToIndex = useCallback(
|
||||||
|
(index: number, segmentProgress = 0) => {
|
||||||
|
if (!images.length) return;
|
||||||
|
|
||||||
|
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
|
||||||
|
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||||
|
const now = performance.now();
|
||||||
|
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
|
||||||
|
|
||||||
|
idxRef.current = nextIndex;
|
||||||
|
setIdx(nextIndex);
|
||||||
|
segStartRef.current = now - nextSegmentProgress * segmentDuration;
|
||||||
|
lastUiUpdateRef.current = now;
|
||||||
|
syncProgress(nextIndex, nextSegmentProgress, true);
|
||||||
|
setMediaSyncToken((token) => token + 1);
|
||||||
|
},
|
||||||
|
[fallbackDuration, images.length, segmentDurations, syncProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const seekTo = useCallback(
|
||||||
|
(ratio: number) => {
|
||||||
|
if (!images.length || totalDurationMs <= 0) return;
|
||||||
|
|
||||||
|
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||||
|
const targetMs = Math.min(
|
||||||
|
totalDurationMs - 1,
|
||||||
|
clampedRatio * totalDurationMs,
|
||||||
|
);
|
||||||
|
let accumulatedMs = 0;
|
||||||
|
let targetIndex = 0;
|
||||||
|
let segmentElapsedMs = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < segmentDurations.length; i++) {
|
||||||
|
const duration = segmentDurations[i] ?? fallbackDuration;
|
||||||
|
if (
|
||||||
|
targetMs < accumulatedMs + duration ||
|
||||||
|
i === segmentDurations.length - 1
|
||||||
|
) {
|
||||||
|
targetIndex = i;
|
||||||
|
segmentElapsedMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(duration, targetMs - accumulatedMs),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
accumulatedMs += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
|
||||||
|
const nextSegmentProgress =
|
||||||
|
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
|
||||||
|
goToIndex(targetIndex, nextSegmentProgress);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
fallbackDuration,
|
||||||
|
goToIndex,
|
||||||
|
images.length,
|
||||||
|
segmentDurations,
|
||||||
|
totalDurationMs,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
idxRef.current = idx;
|
idxRef.current = idx;
|
||||||
}, [idx]);
|
}, [idx]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validIds = new Set(images.map((img) => img.id));
|
||||||
|
setDurationOverrides((current) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const [id, duration] of Object.entries(current)) {
|
||||||
|
if (validIds.has(id)) {
|
||||||
|
next[id] = duration;
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : current;
|
||||||
|
});
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
idxRef.current = 0;
|
||||||
|
setIdx(0);
|
||||||
|
segStartRef.current = null;
|
||||||
|
segProgressRef.current = 0;
|
||||||
|
totalProgressRef.current = 0;
|
||||||
|
setSegProgress(0);
|
||||||
|
setProgress(0);
|
||||||
|
setMediaSyncToken((token) => token + 1);
|
||||||
|
}, [imageKey, setProgress]);
|
||||||
|
|
||||||
// BGM 控制
|
// BGM 控制
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = audioRef.current;
|
const el = audioRef.current;
|
||||||
@ -51,33 +238,42 @@ export function useImageCarousel({
|
|||||||
}
|
}
|
||||||
}, [audioRef, isPlaying, volume]);
|
}, [audioRef, isPlaying, volume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
};
|
||||||
|
}, [audioRef]);
|
||||||
|
|
||||||
// 自动切页
|
// 自动切页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!images?.length) return;
|
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
const now = performance.now();
|
||||||
let lastTs = performance.now();
|
const currentSegmentDuration =
|
||||||
|
segmentDurations[idxRef.current] ?? fallbackDuration;
|
||||||
|
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||||
|
|
||||||
const tick = (ts: number) => {
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
if (!images?.length) return;
|
if (!images?.length) return;
|
||||||
|
|
||||||
if (!isPlaying) segStartRef.current! += ts - lastTs;
|
const ts = performance.now();
|
||||||
lastTs = ts;
|
|
||||||
|
|
||||||
let start = segStartRef.current!;
|
let start = segStartRef.current!;
|
||||||
let localIdx = idxRef.current;
|
let localIdx = idxRef.current;
|
||||||
|
|
||||||
let elapsed = ts - start;
|
let elapsed = ts - start;
|
||||||
|
let currentSegmentDuration =
|
||||||
// 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs)
|
segmentDurations[localIdx] ?? fallbackDuration;
|
||||||
const getCurrentSegmentDuration = (index: number) => {
|
|
||||||
const img = images[index];
|
|
||||||
return img?.duration ?? segmentMs;
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
|
||||||
|
|
||||||
while (elapsed >= currentSegmentDuration) {
|
while (elapsed >= currentSegmentDuration) {
|
||||||
|
if (loopMode === "single") {
|
||||||
|
elapsed %= currentSegmentDuration;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
elapsed -= currentSegmentDuration;
|
elapsed -= currentSegmentDuration;
|
||||||
|
|
||||||
if (localIdx >= images.length - 1) {
|
if (localIdx >= images.length - 1) {
|
||||||
@ -89,44 +285,60 @@ export function useImageCarousel({
|
|||||||
} else {
|
} else {
|
||||||
localIdx = localIdx + 1;
|
localIdx = localIdx + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新下一张图片的时长
|
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
|
||||||
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
|
||||||
}
|
}
|
||||||
segStartRef.current = ts - elapsed;
|
segStartRef.current = ts - elapsed;
|
||||||
|
|
||||||
if (localIdx !== idxRef.current) {
|
const indexChanged = localIdx !== idxRef.current;
|
||||||
|
if (indexChanged) {
|
||||||
idxRef.current = localIdx;
|
idxRef.current = localIdx;
|
||||||
setIdx(localIdx);
|
setIdx(localIdx);
|
||||||
// 虚拟滚动不需要实际滚动 DOM
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
|
const localSeg = Math.max(
|
||||||
setSegProgress(localSeg);
|
0,
|
||||||
|
Math.min(1, elapsed / currentSegmentDuration),
|
||||||
// 计算总进度:已完成的图片 + 当前图片的进度
|
);
|
||||||
let totalProgress = 0;
|
|
||||||
for (let i = 0; i < localIdx; i++) {
|
|
||||||
totalProgress += 1;
|
|
||||||
}
|
|
||||||
totalProgress += localSeg;
|
|
||||||
setProgress(totalProgress / images.length);
|
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
if (
|
||||||
|
indexChanged ||
|
||||||
|
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
|
||||||
|
) {
|
||||||
|
lastUiUpdateRef.current = ts;
|
||||||
|
syncProgress(localIdx, localSeg, indexChanged);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
tick();
|
||||||
|
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||||
rafRef.current = null;
|
timerRef.current = null;
|
||||||
};
|
};
|
||||||
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
}, [
|
||||||
|
fallbackDuration,
|
||||||
|
images,
|
||||||
|
isPlaying,
|
||||||
|
loopMode,
|
||||||
|
neighbors?.next,
|
||||||
|
router,
|
||||||
|
segmentDurations,
|
||||||
|
syncProgress,
|
||||||
|
totalDurationMs,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
idx,
|
idx,
|
||||||
setIdx,
|
setIdx,
|
||||||
segProgress,
|
segProgress,
|
||||||
|
segmentDurations,
|
||||||
|
totalDurationMs,
|
||||||
|
mediaSyncToken,
|
||||||
segStartRef,
|
segStartRef,
|
||||||
idxRef,
|
idxRef,
|
||||||
|
setSegmentDuration,
|
||||||
|
goToIndex,
|
||||||
|
seekTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,11 @@ export function useNavigation({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { LoopMode, ObjectFit } from "../types.ts";
|
import type { LoopMode, ObjectFit } from "../types.ts";
|
||||||
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
|
import {
|
||||||
|
getNumberFromStorage,
|
||||||
|
getStringFromStorage,
|
||||||
|
saveToStorage,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
export function usePlayerState() {
|
export function usePlayerState() {
|
||||||
const [isPlaying, setIsPlaying] = useState(true);
|
const [isPlaying, setIsPlaying] = useState(true);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1));
|
const [volume, setVolume] = useState(() =>
|
||||||
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1));
|
getNumberFromStorage("aweme_player_volume", 1),
|
||||||
|
);
|
||||||
|
const [rate, setRate] = useState(() =>
|
||||||
|
getNumberFromStorage("aweme_player_rate", 1),
|
||||||
|
);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
const [progressRestored, setProgressRestored] = useState(false);
|
const [progressRestored, setProgressRestored] = useState(false);
|
||||||
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
|
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
|
||||||
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
|
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
|
||||||
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
|
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
|
||||||
return saved === "sequential" ? "sequential" : "loop";
|
if (saved === "single" || saved === "sequential") return saved;
|
||||||
|
return "loop";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 持久化音量
|
// 持久化音量
|
||||||
|
|||||||
@ -51,7 +51,11 @@ export function useVideoPlayer({
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fiveMinutes = 5 * 60 * 1000;
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
|
|
||||||
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
|
if (
|
||||||
|
now - timestamp < fiveMinutes &&
|
||||||
|
time > 1 &&
|
||||||
|
time < v.duration - 1
|
||||||
|
) {
|
||||||
v.currentTime = time;
|
v.currentTime = time;
|
||||||
console.log(`恢复播放进度: ${Math.round(time)}s`);
|
console.log(`恢复播放进度: ${Math.round(time)}s`);
|
||||||
} else if (now - timestamp >= fiveMinutes) {
|
} else if (now - timestamp >= fiveMinutes) {
|
||||||
@ -125,7 +129,7 @@ export function useVideoPlayer({
|
|||||||
v.addEventListener("play", onPlay);
|
v.addEventListener("play", onPlay);
|
||||||
v.addEventListener("pause", onPause);
|
v.addEventListener("pause", onPause);
|
||||||
v.addEventListener("ended", onEnded);
|
v.addEventListener("ended", onEnded);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("timeupdate", onTime);
|
v.removeEventListener("timeupdate", onTime);
|
||||||
v.removeEventListener("loadedmetadata", onTime);
|
v.removeEventListener("loadedmetadata", onTime);
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import type { Metadata } from "next";
|
|||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { AwemeData, VideoTranscript } from "./types";
|
import { AwemeData, VideoTranscript } from "./types";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ awemeId: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
const id = (await params).awemeId;
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
@ -16,7 +20,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
|||||||
prisma.imagePost.findUnique({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
select: { desc: true, author: { select: { nickname: true } } },
|
select: { desc: true, author: { select: { nickname: true } } },
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data = video || post;
|
const data = video || post;
|
||||||
@ -28,7 +32,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
|||||||
|
|
||||||
const desc = data.desc || "查看作品详情";
|
const desc = data.desc || "查看作品详情";
|
||||||
const author = data.author.nickname;
|
const author = data.author.nickname;
|
||||||
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`;
|
const title =
|
||||||
|
desc.length > 50
|
||||||
|
? `${desc.slice(0, 50)}... - ${author}`
|
||||||
|
: `${desc} - ${author}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@ -36,7 +43,11 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
export default async function AwemeDetail({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ awemeId: string }>;
|
||||||
|
}) {
|
||||||
const id = (await params).awemeId;
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
@ -47,7 +58,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
prisma.imagePost.findUnique({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
include: { author: true, images: { orderBy: { order: "asc" } } },
|
include: { author: true, images: { orderBy: { order: "asc" } } },
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||||
@ -66,65 +77,109 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
created_at: aweme!.created_at,
|
created_at: aweme!.created_at,
|
||||||
likesCount: Number(aweme!.digg_count),
|
likesCount: Number(aweme!.digg_count),
|
||||||
commentsCount,
|
commentsCount,
|
||||||
author: {
|
author: {
|
||||||
nickname: aweme!.author.nickname,
|
nickname: aweme!.author.nickname,
|
||||||
avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png'),
|
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
|
||||||
sec_uid: aweme!.author.sec_uid
|
sec_uid: aweme!.author.sec_uid,
|
||||||
},
|
},
|
||||||
...(() => {
|
...(() => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const aweme = video!
|
const aweme = video!;
|
||||||
return {
|
return {
|
||||||
type: "video" as const,
|
type: "video" as const,
|
||||||
cover_url: getFileUrl(aweme!.cover_url ?? 'default-cover.png'),
|
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
|
||||||
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
||||||
duration_ms: aweme!.duration_ms,
|
duration_ms: aweme!.duration_ms,
|
||||||
video_url: getFileUrl(aweme!.video_url),
|
video_url: getFileUrl(aweme!.video_url),
|
||||||
width: aweme!.width ?? null,
|
width: aweme!.width ?? null,
|
||||||
height: aweme!.height ?? null,
|
height: aweme!.height ?? null,
|
||||||
}
|
};
|
||||||
} else {
|
} else {
|
||||||
const aweme = post!
|
const aweme = post!;
|
||||||
return {
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
cover_url: getFileUrl(aweme!.images[0].url ?? 'default-cover.png'),
|
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
|
||||||
cover_size: { h: aweme!.images[0].height ?? 0, w: aweme!.images[0].width ?? 0 },
|
cover_size: {
|
||||||
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: img.animated? getFileUrl(img.animated) : null })),
|
h: aweme!.images[0].height ?? 0,
|
||||||
music_url: getFileUrl(aweme!.music_url || 'default-music.mp3'),
|
w: aweme!.images[0].width ?? 0,
|
||||||
|
},
|
||||||
|
images: aweme!.images.map((img) => ({
|
||||||
|
...img,
|
||||||
|
url: getFileUrl(img.url),
|
||||||
|
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||||
|
})),
|
||||||
|
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})()
|
})(),
|
||||||
}
|
};
|
||||||
|
|
||||||
const transcript: VideoTranscript | null = isVideo ? await prisma.videoTranscript.findUnique({
|
const transcript: VideoTranscript | null = isVideo
|
||||||
where: { videoId: id },
|
? await prisma.videoTranscript.findUnique({
|
||||||
}) : null;
|
where: { videoId: id },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
// Compute prev/next neighbors by created_at across videos and image posts
|
// Compute prev/next neighbors by created_at across videos and image posts
|
||||||
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at);
|
const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
|
||||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||||
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
prisma.video.findFirst({
|
||||||
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
where: { created_at: { gt: currentCreatedAt } },
|
||||||
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
orderBy: { created_at: "asc" },
|
||||||
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findFirst({
|
||||||
|
where: { created_at: { gt: currentCreatedAt } },
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
prisma.video.findFirst({
|
||||||
|
where: { created_at: { lt: currentCreatedAt } },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findFirst({
|
||||||
|
where: { created_at: { lt: currentCreatedAt } },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const pickPrev = (() => {
|
const pickPrev = (() => {
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
const cands: { aweme_id: string; created_at: Date }[] = [];
|
||||||
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at });
|
if (newerVideo)
|
||||||
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at });
|
cands.push({
|
||||||
|
aweme_id: newerVideo.aweme_id,
|
||||||
|
created_at: newerVideo.created_at,
|
||||||
|
});
|
||||||
|
if (newerPost)
|
||||||
|
cands.push({
|
||||||
|
aweme_id: newerPost.aweme_id,
|
||||||
|
created_at: newerPost.created_at,
|
||||||
|
});
|
||||||
if (cands.length === 0) return null;
|
if (cands.length === 0) return null;
|
||||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||||
return { aweme_id: cands[0].aweme_id };
|
return { aweme_id: cands[0].aweme_id };
|
||||||
})();
|
})();
|
||||||
const pickNext = (() => {
|
const pickNext = (() => {
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
const cands: { aweme_id: string; created_at: Date }[] = [];
|
||||||
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at });
|
if (olderVideo)
|
||||||
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at });
|
cands.push({
|
||||||
|
aweme_id: olderVideo.aweme_id,
|
||||||
|
created_at: olderVideo.created_at,
|
||||||
|
});
|
||||||
|
if (olderPost)
|
||||||
|
cands.push({
|
||||||
|
aweme_id: olderPost.aweme_id,
|
||||||
|
created_at: olderPost.created_at,
|
||||||
|
});
|
||||||
if (cands.length === 0) return null;
|
if (cands.length === 0) return null;
|
||||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||||
return { aweme_id: cands[0].aweme_id };
|
return { aweme_id: cands[0].aweme_id };
|
||||||
})();
|
})();
|
||||||
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
|
const neighbors: {
|
||||||
|
prev: { aweme_id: string } | null;
|
||||||
|
next: { aweme_id: string } | null;
|
||||||
|
} = { prev: pickPrev, next: pickNext };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full">
|
<main className="min-h-screen w-full">
|
||||||
@ -135,7 +190,11 @@ 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"
|
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>
|
</div>
|
||||||
<AwemeDetailClient data={data} neighbors={neighbors} transcript={transcript} />
|
<AwemeDetailClient
|
||||||
|
data={data}
|
||||||
|
neighbors={neighbors}
|
||||||
|
transcript={transcript}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
export type User = { nickname: string; avatar_url: string | null; sec_uid?: string };
|
export type User = {
|
||||||
|
nickname: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
sec_uid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
cid: string;
|
cid: string;
|
||||||
@ -13,7 +17,7 @@ export type VideoData = {
|
|||||||
type: "video";
|
type: "video";
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
cover_size: {w: number; h: number};
|
cover_size: { w: number; h: number };
|
||||||
desc: string;
|
desc: string;
|
||||||
created_at: string | Date;
|
created_at: string | Date;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
@ -29,10 +33,17 @@ export type ImageData = {
|
|||||||
type: "image";
|
type: "image";
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
cover_size: {w: number; h: number};
|
cover_size: { w: number; h: number };
|
||||||
desc: string;
|
desc: string;
|
||||||
created_at: string | Date;
|
created_at: string | Date;
|
||||||
images: { id: string; url: string; width: number | null; height: number | null; animated: string | null; duration: number | null }[];
|
images: {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
animated: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
}[];
|
||||||
music_url: string | null;
|
music_url: string | null;
|
||||||
author: User;
|
author: User;
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
@ -52,5 +63,5 @@ export type Neighbors = {
|
|||||||
next: { aweme_id: string } | null;
|
next: { aweme_id: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoopMode = "loop" | "sequential";
|
export type LoopMode = "loop" | "single" | "sequential";
|
||||||
export type ObjectFit = "contain" | "cover";
|
export type ObjectFit = "contain" | "cover";
|
||||||
|
|||||||
@ -26,7 +26,9 @@ export function formatTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理评论文本中的表情占位符
|
// 处理评论文本中的表情占位符
|
||||||
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
|
export function parseCommentText(
|
||||||
|
text: string,
|
||||||
|
): (string | { type: "emoji"; name: string })[] {
|
||||||
const parts: (string | { type: "emoji"; name: string })[] = [];
|
const parts: (string | { type: "emoji"; name: string })[] = [];
|
||||||
const regex = /\[([^\]]+)\]/g;
|
const regex = /\[([^\]]+)\]/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从 localStorage 获取数值
|
// 从 localStorage 获取数值
|
||||||
export function getNumberFromStorage(key: string, defaultValue: number): number {
|
export function getNumberFromStorage(
|
||||||
|
key: string,
|
||||||
|
defaultValue: number,
|
||||||
|
): number {
|
||||||
if (typeof window === "undefined") return defaultValue;
|
if (typeof window === "undefined") return defaultValue;
|
||||||
const saved = localStorage.getItem(key);
|
const saved = localStorage.getItem(key);
|
||||||
if (!saved) return defaultValue;
|
if (!saved) return defaultValue;
|
||||||
@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从 localStorage 获取字符串
|
// 从 localStorage 获取字符串
|
||||||
export function getStringFromStorage(key: string, defaultValue: string): string {
|
export function getStringFromStorage(
|
||||||
|
key: string,
|
||||||
|
defaultValue: string,
|
||||||
|
): string {
|
||||||
if (typeof window === "undefined") return defaultValue;
|
if (typeof window === "undefined") return defaultValue;
|
||||||
return localStorage.getItem(key) || defaultValue;
|
return localStorage.getItem(key) || defaultValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
type BackButtonProps = {
|
type BackButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -18,28 +18,37 @@ type BackButtonProps = {
|
|||||||
* - Fallback: if close fails (e.g., not opened by script), navigates to '/'
|
* - Fallback: if close fails (e.g., not opened by script), navigates to '/'
|
||||||
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
|
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
|
||||||
*/
|
*/
|
||||||
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) {
|
export default function BackButton({
|
||||||
|
className,
|
||||||
|
ariaLabel = "返回",
|
||||||
|
hrefFallback = "/",
|
||||||
|
children,
|
||||||
|
}: BackButtonProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => {
|
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>(
|
||||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
(e) => {
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
||||||
|
return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Try to close the window first
|
// Try to close the window first
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.close();
|
window.close();
|
||||||
|
|
||||||
// If window.close() didn't work (window still open after a short delay),
|
// If window.close() didn't work (window still open after a short delay),
|
||||||
// navigate to the fallback URL
|
// navigate to the fallback URL
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
router.push(hrefFallback);
|
router.push(hrefFallback);
|
||||||
}
|
}
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
}, [router, hrefFallback]);
|
},
|
||||||
|
[router, hrefFallback],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import HoverVideo from './HoverVideo';
|
import HoverVideo from "./HoverVideo";
|
||||||
import { ThumbsUp } from 'lucide-react';
|
import { ThumbsUp } from "lucide-react";
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialItems: FeedItem[];
|
initialItems: FeedItem[];
|
||||||
@ -11,7 +11,11 @@ type Props = {
|
|||||||
fetchUrl?: string;
|
fetchUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: Props) {
|
export default function FeedMasonry({
|
||||||
|
initialItems,
|
||||||
|
initialCursor,
|
||||||
|
fetchUrl = "/api/feed",
|
||||||
|
}: Props) {
|
||||||
// 哨兵与容器
|
// 哨兵与容器
|
||||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -22,11 +26,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
|
|
||||||
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
||||||
const getColumnCount = useCallback(() => {
|
const getColumnCount = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return 1;
|
if (typeof window === "undefined") return 1;
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
if (w >= 1280) return 4; // xl
|
if (w >= 1280) return 4; // xl
|
||||||
if (w >= 1024) return 3; // lg
|
if (w >= 1024) return 3; // lg
|
||||||
if (w >= 640) return 2; // sm
|
if (w >= 640) return 2; // sm
|
||||||
return 1;
|
return 1;
|
||||||
}, []);
|
}, []);
|
||||||
// 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
|
// 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
|
||||||
@ -37,8 +41,8 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
// 挂载后立即根据当前窗口宽度更新一次列数
|
// 挂载后立即根据当前窗口宽度更新一次列数
|
||||||
setColumnCount(getColumnCount());
|
setColumnCount(getColumnCount());
|
||||||
const onResize = () => setColumnCount(getColumnCount());
|
const onResize = () => setColumnCount(getColumnCount());
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener("resize", onResize);
|
||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, [getColumnCount]);
|
}, [getColumnCount]);
|
||||||
|
|
||||||
// 估算卡片高度(用于分配到“最短列”)
|
// 估算卡片高度(用于分配到“最短列”)
|
||||||
@ -46,8 +50,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
// 媒体区域高度
|
// 媒体区域高度
|
||||||
let mediaH = 200; // fallback
|
let mediaH = 200; // fallback
|
||||||
if (item.width && item.height) {
|
if (item.width && item.height) {
|
||||||
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth);
|
mediaH = Math.max(
|
||||||
} else if (item.type === 'video') {
|
80,
|
||||||
|
(Number(item.height) / Number(item.width)) * colWidth,
|
||||||
|
);
|
||||||
|
} else if (item.type === "video") {
|
||||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||||
}
|
}
|
||||||
// 文本 + 作者栏的高度粗估
|
// 文本 + 作者栏的高度粗估
|
||||||
@ -62,12 +69,15 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||||
return cols;
|
return cols;
|
||||||
});
|
});
|
||||||
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0));
|
const [colHeights, setColHeights] = useState<number[]>(() =>
|
||||||
|
Array.from({ length: columnCount }, () => 0),
|
||||||
|
);
|
||||||
|
|
||||||
// 初始化与当列数变化时重排
|
// 初始化与当列数变化时重排
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
const colWidth =
|
||||||
|
columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||||
// 用 initialItems 重排
|
// 用 initialItems 重排
|
||||||
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||||
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
||||||
@ -90,16 +100,19 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (cursor) params.set('before', cursor);
|
if (cursor) params.set("before", cursor);
|
||||||
params.set('limit', '24');
|
params.set("limit", "24");
|
||||||
const url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`;
|
const url = fetchUrl.includes("?")
|
||||||
const res = await fetch(url, { cache: 'no-store' });
|
? `${fetchUrl}&${params.toString()}`
|
||||||
|
: `${fetchUrl}?${params.toString()}`;
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data: FeedResponse = await res.json();
|
const data: FeedResponse = await res.json();
|
||||||
// 将新数据按最短列分配
|
// 将新数据按最短列分配
|
||||||
setColumns((prevCols) => {
|
setColumns((prevCols) => {
|
||||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
const colWidth =
|
||||||
|
columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||||
const cols = prevCols.map((c) => [...c]);
|
const cols = prevCols.map((c) => [...c]);
|
||||||
const heights = [...colHeights];
|
const heights = [...colHeights];
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
@ -117,7 +130,7 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
setCursor(data.nextCursor);
|
setCursor(data.nextCursor);
|
||||||
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('fetch more feed failed', e);
|
console.error("fetch more feed failed", e);
|
||||||
// 失败也不要死循环
|
// 失败也不要死循环
|
||||||
setEnded(true);
|
setEnded(true);
|
||||||
} finally {
|
} finally {
|
||||||
@ -128,88 +141,129 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const io = new IntersectionObserver((entries) => {
|
const io = new IntersectionObserver(
|
||||||
const entry = entries[0];
|
(entries) => {
|
||||||
if (entry.isIntersecting) {
|
const entry = entries[0];
|
||||||
fetchMore();
|
if (entry.isIntersecting) {
|
||||||
}
|
fetchMore();
|
||||||
}, { rootMargin: '800px 0px 800px 0px' });
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "800px 0px 800px 0px" },
|
||||||
|
);
|
||||||
io.observe(el);
|
io.observe(el);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [fetchMore]);
|
}, [fetchMore]);
|
||||||
|
|
||||||
const renderCard = useCallback((item: FeedItem) => (
|
const renderCard = useCallback(
|
||||||
<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">
|
(item: FeedItem) => (
|
||||||
<Link href={`/aweme/${item.aweme_id}`} target="_blank" className="block relative w-full">
|
<article
|
||||||
<div
|
key={item.aweme_id}
|
||||||
className="relative w-full"
|
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"
|
||||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
>
|
||||||
|
<Link
|
||||||
|
href={`/aweme/${item.aweme_id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="block relative w-full"
|
||||||
>
|
>
|
||||||
{item.type === 'video' ? (
|
<div
|
||||||
<HoverVideo
|
className="relative w-full"
|
||||||
videoUrl={(item as any).video_url}
|
style={{
|
||||||
coverUrl={item.cover_url}
|
aspectRatio:
|
||||||
className="absolute inset-0 w-full h-full"
|
`${item.width && item.height ? `${item.width}/${item.height}` : ""}` as any,
|
||||||
/>
|
}}
|
||||||
) : (
|
>
|
||||||
<img
|
{item.type === "video" ? (
|
||||||
loading="lazy"
|
<HoverVideo
|
||||||
src={item.cover_url || '/placeholder.svg'}
|
videoUrl={(item as any).video_url}
|
||||||
alt={item.desc?.slice(0, 20) || 'image'}
|
coverUrl={item.cover_url}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full"
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
<img
|
||||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
loading="lazy"
|
||||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
src={item.cover_url || "/placeholder.svg"}
|
||||||
{item.desc}
|
alt={item.desc?.slice(0, 20) || "image"}
|
||||||
</p>
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
<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 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>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 p-3">
|
<div className="flex items-center gap-2 p-3">
|
||||||
{item.author.sec_uid ? (
|
{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">
|
<Link
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
href={`/author/${item.author.sec_uid}`}
|
||||||
{item.author.avatar_url ? (
|
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
>
|
||||||
) : null}
|
<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>
|
</div>
|
||||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
)}
|
||||||
</Link>
|
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||||
) : (
|
{item.likes}{" "}
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
</span>
|
||||||
{item.author.avatar_url ? (
|
</div>
|
||||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
</article>
|
||||||
) : 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>
|
|
||||||
), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||||
<div ref={containerRef} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="grid gap-4"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{columns.map((col, idx) => (
|
{columns.map((col, idx) => (
|
||||||
<div key={idx} className="flex flex-col">
|
<div key={idx} className="flex flex-col">
|
||||||
{col.map((item) => renderCard(item))}
|
{col.map((item) => renderCard(item))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500">
|
<div
|
||||||
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')}
|
ref={sentinelRef}
|
||||||
|
className="h-10 flex items-center justify-center text-sm text-zinc-500"
|
||||||
|
>
|
||||||
|
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
type HoverVideoProps = {
|
type HoverVideoProps = {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
@ -12,7 +12,12 @@ type HoverVideoProps = {
|
|||||||
/**
|
/**
|
||||||
* 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。
|
* 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。
|
||||||
*/
|
*/
|
||||||
export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) {
|
export default function HoverVideo({
|
||||||
|
videoUrl,
|
||||||
|
coverUrl,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: HoverVideoProps) {
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
@ -38,14 +43,19 @@ export default function HoverVideo({ videoUrl, coverUrl, className, style }: Hov
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
<div
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onMouseEnter={onEnter}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
>
|
||||||
{/* 封面始终渲染在底层 */}
|
{/* 封面始终渲染在底层 */}
|
||||||
<img
|
<img
|
||||||
src={coverUrl || '/placeholder.svg'}
|
src={coverUrl || "/placeholder.svg"}
|
||||||
alt="cover"
|
alt="cover"
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading='lazy'
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
|
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #161823; /* theme background */
|
--background: #161823; /* theme background */
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
|
--font-geist-sans:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
--font-geist-mono:
|
||||||
|
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@ -23,11 +28,13 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条隐藏 */
|
/* 滚动条隐藏 */
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 搜索结果高亮标记样式 */
|
/* 搜索结果高亮标记样式 */
|
||||||
mark {
|
mark {
|
||||||
@ -37,12 +44,15 @@ mark {
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.h-screen{
|
.h-screen {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.min-h-screen{
|
.min-h-screen {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "抖歪 - 记录当下时代",
|
default: "抖歪 - 记录当下时代",
|
||||||
@ -30,11 +19,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<body
|
<body className="antialiased">{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/page.tsx
31
app/page.tsx
@ -33,11 +33,15 @@ export default async function Home() {
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? ''),
|
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
@ -47,8 +51,12 @@ export default async function Home() {
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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(() => Math.random() - 0.5)
|
||||||
@ -65,7 +73,7 @@ export default async function Home() {
|
|||||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-center mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-center mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400">
|
||||||
Douyin Archive
|
Douyin Archive
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-xl mb-12">
|
<div className="w-full max-w-xl mb-12">
|
||||||
<SearchBox />
|
<SearchBox />
|
||||||
</div>
|
</div>
|
||||||
@ -73,8 +81,15 @@ export default async function Home() {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(() => {
|
{(() => {
|
||||||
const initial = feed.slice(0, 24);
|
const initial = feed.slice(0, 24);
|
||||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
const cursor =
|
||||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
initial.length > 0
|
||||||
|
? new Date(
|
||||||
|
initial[initial.length - 1].created_at as any,
|
||||||
|
).toISOString()
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<FeedMasonry initialItems={initial} initialCursor={cursor} />
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
|
|||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
id: string;
|
id: string;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
type: 'video' | 'image';
|
type: "video" | "image";
|
||||||
rank: number;
|
rank: number;
|
||||||
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
||||||
video?: {
|
video?: {
|
||||||
@ -35,7 +35,11 @@ type SearchResultItem = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export default function SearchClient({ initialQuery }: { initialQuery: string }) {
|
export default function SearchClient({
|
||||||
|
initialQuery,
|
||||||
|
}: {
|
||||||
|
initialQuery: string;
|
||||||
|
}) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -53,7 +57,9 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`);
|
const res = await fetch(
|
||||||
|
`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`,
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error("Search failed");
|
if (!res.ok) throw new Error("Search failed");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setResults(data.results || []);
|
setResults(data.results || []);
|
||||||
@ -101,7 +107,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<ArrowLeft size={24} />
|
<ArrowLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="flex-1 flex items-center gap-3">
|
<form
|
||||||
|
onSubmit={handleSearch}
|
||||||
|
className="flex-1 flex items-center gap-3"
|
||||||
|
>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -160,7 +169,9 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
||||||
<Search size={64} className="mb-4 opacity-20" />
|
<Search size={64} className="mb-4 opacity-20" />
|
||||||
<p className="text-xl">输入关键词开始搜索</p>
|
<p className="text-xl">输入关键词开始搜索</p>
|
||||||
<p className="text-sm mt-2">支持搜索视频描述、图文描述及语音转写内容</p>
|
<p className="text-sm mt-2">
|
||||||
|
支持搜索视频描述、图文描述及语音转写内容
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -168,14 +179,19 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h2 className="text-lg text-white/80">
|
<h2 className="text-lg text-white/80">
|
||||||
找到 <span className="text-white font-semibold">{results.length}</span> 个结果
|
找到{" "}
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{results.length}
|
||||||
|
</span>{" "}
|
||||||
|
个结果
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 单列列表布局 */}
|
{/* 单列列表布局 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{results.map((item) => {
|
{results.map((item) => {
|
||||||
const content = item.type === 'video' ? item.video : item.imagePost;
|
const content =
|
||||||
|
item.type === "video" ? item.video : item.imagePost;
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -202,12 +218,14 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{/* 类型标签 */}
|
{/* 类型标签 */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
<span
|
||||||
item.type === 'video'
|
className={`px-2 py-0.5 text-xs rounded ${
|
||||||
? 'bg-blue-600/20 text-blue-300'
|
item.type === "video"
|
||||||
: 'bg-purple-600/20 text-purple-300'
|
? "bg-blue-600/20 text-blue-300"
|
||||||
}`}>
|
: "bg-purple-600/20 text-purple-300"
|
||||||
{item.type === 'video' ? '视频' : '图文'}
|
}`}
|
||||||
|
>
|
||||||
|
{item.type === "video" ? "视频" : "图文"}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
|
<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} />
|
<MessageSquare size={12} />
|
||||||
@ -216,7 +234,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 描述 */}
|
{/* 描述 */}
|
||||||
<Link href={`/aweme/${content.aweme_id}`} target="_blank">
|
<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">
|
<p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors">
|
||||||
{content.desc || "无描述"}
|
{content.desc || "无描述"}
|
||||||
</p>
|
</p>
|
||||||
@ -245,7 +266,7 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
||||||
dangerouslySetInnerHTML={{ __html: item.snippet }}
|
dangerouslySetInnerHTML={{ __html: item.snippet }}
|
||||||
style={{
|
style={{
|
||||||
wordBreak: 'break-word',
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import SearchClient from "./SearchClient";
|
import SearchClient from "./SearchClient";
|
||||||
|
|
||||||
export default function Page({ searchParams }: { searchParams: { q?: string } }) {
|
export default function Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { q?: string };
|
||||||
|
}) {
|
||||||
const q = typeof searchParams.q === "string" ? searchParams.q : "";
|
const q = typeof searchParams.q === "string" ? searchParams.q : "";
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="p-6 text-zinc-400">Loading…</div>}>
|
<Suspense fallback={<div className="p-6 text-zinc-400">Loading…</div>}>
|
||||||
|
|||||||
@ -1,7 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertTriangle, CheckCircle2, Clipboard, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clipboard,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
PlayCircle,
|
||||||
|
Plus,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
type TaskStatus = "pending" | "running" | "success" | "error";
|
type TaskStatus = "pending" | "running" | "success" | "error";
|
||||||
|
|
||||||
@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => {
|
|||||||
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
||||||
const cleaned = matches
|
const cleaned = matches
|
||||||
.map((m) => m.replace(trailing, ""))
|
.map((m) => m.replace(trailing, ""))
|
||||||
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
|
.map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾
|
||||||
// 去重
|
// 去重
|
||||||
return Array.from(new Set(cleaned));
|
return Array.from(new Set(cleaned));
|
||||||
};
|
};
|
||||||
@ -43,43 +56,58 @@ export default function TasksPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const inProgressUrls = useMemo(
|
const inProgressUrls = useMemo(
|
||||||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
() =>
|
||||||
[tasks]
|
new Set(
|
||||||
|
tasks
|
||||||
|
.filter((t) => t.status === "pending" || t.status === "running")
|
||||||
|
.map((t) => t.url),
|
||||||
|
),
|
||||||
|
[tasks],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addTasks = useCallback((urls: string[]) => {
|
const addTasks = useCallback(
|
||||||
if (!urls.length) return;
|
(urls: string[]) => {
|
||||||
const now = Date.now();
|
if (!urls.length) return;
|
||||||
setTasks((prev) => {
|
const now = Date.now();
|
||||||
const existing = new Set(prev.map((t) => t.id));
|
setTasks((prev) => {
|
||||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
const existing = new Set(prev.map((t) => t.id));
|
||||||
const newTasks: Task[] = [];
|
const notDuplicated = urls.filter((u) => !inProgressUrls.has(u));
|
||||||
for (const url of notDuplicated) {
|
const newTasks: Task[] = [];
|
||||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
for (const url of notDuplicated) {
|
||||||
newTasks.push({ id, url, status: "pending" });
|
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
newTasks.push({ id, url, status: "pending" });
|
||||||
// 新任务添加到最前面
|
}
|
||||||
return [...newTasks, ...prev];
|
// 新任务添加到最前面
|
||||||
});
|
return [...newTasks, ...prev];
|
||||||
}, [inProgressUrls]);
|
});
|
||||||
|
},
|
||||||
|
[inProgressUrls],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
e?.preventDefault();
|
(e?: React.FormEvent) => {
|
||||||
const urls = extractDouyinLinks(input);
|
e?.preventDefault();
|
||||||
if (!urls.length) {
|
const urls = extractDouyinLinks(input);
|
||||||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
if (!urls.length) {
|
||||||
return;
|
alert(
|
||||||
}
|
"未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本",
|
||||||
addTasks(urls);
|
);
|
||||||
setInput("");
|
return;
|
||||||
}, [input, addTasks]);
|
}
|
||||||
|
addTasks(urls);
|
||||||
|
setInput("");
|
||||||
|
},
|
||||||
|
[input, addTasks],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePasteAndAdd = useCallback(async () => {
|
const handlePasteAndAdd = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
const text = await navigator.clipboard.readText();
|
||||||
const urls = extractDouyinLinks(text);
|
const urls = extractDouyinLinks(text);
|
||||||
if (!urls.length) {
|
if (!urls.length) {
|
||||||
alert("剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本");
|
alert(
|
||||||
|
"剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addTasks(urls);
|
addTasks(urls);
|
||||||
@ -94,22 +122,44 @@ export default function TasksPage() {
|
|||||||
if (controllers.current.has(task.id)) return;
|
if (controllers.current.has(task.id)) return;
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
controllers.current.set(task.id, ctrl);
|
controllers.current.set(task.id, ctrl);
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t));
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === task.id
|
||||||
|
? { ...t, status: "running", startedAt: Date.now(), error: undefined }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
const res = await fetch(
|
||||||
|
`/api/fetcher?url=${encodeURIComponent(task.url)}`,
|
||||||
|
{ signal: ctrl.signal, method: "GET" },
|
||||||
|
);
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// 使用后端返回的结构化错误信息
|
// 使用后端返回的结构化错误信息
|
||||||
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
||||||
const errorCode = data?.code || 'UNKNOWN';
|
const errorCode = data?.code || "UNKNOWN";
|
||||||
throw new Error(`${errorMsg} (${errorCode})`);
|
throw new Error(`${errorMsg} (${errorCode})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === task.id
|
||||||
|
? { ...t, status: "success", finishedAt: Date.now(), result: data }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
const msg =
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t));
|
err?.name === "AbortError" ? "已取消" : err?.message || String(err);
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === task.id
|
||||||
|
? { ...t, status: "error", finishedAt: Date.now(), error: msg }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
controllers.current.delete(task.id);
|
controllers.current.delete(task.id);
|
||||||
}
|
}
|
||||||
@ -117,17 +167,17 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = tasks.filter(t => t.status === "pending");
|
const pending = tasks.filter((t) => t.status === "pending");
|
||||||
pending.forEach((t) => startTask(t));
|
pending.forEach((t) => startTask(t));
|
||||||
}, [tasks, startTask]);
|
}, [tasks, startTask]);
|
||||||
|
|
||||||
// 定时器更新运行中任务的耗时显示
|
// 定时器更新运行中任务的耗时显示
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasRunningTasks = tasks.some(t => t.status === "running");
|
const hasRunningTasks = tasks.some((t) => t.status === "running");
|
||||||
if (!hasRunningTasks) return;
|
if (!hasRunningTasks) return;
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setTick(prev => prev + 1);
|
setTick((prev) => prev + 1);
|
||||||
}, 1000); // 每秒更新一次
|
}, 1000); // 每秒更新一次
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
@ -140,27 +190,40 @@ export default function TasksPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const retryTask = useCallback((taskId: string) => {
|
const retryTask = useCallback((taskId: string) => {
|
||||||
setTasks(prev => prev.map(t => {
|
setTasks((prev) =>
|
||||||
if (t.id === taskId) {
|
prev.map((t) => {
|
||||||
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined };
|
if (t.id === taskId) {
|
||||||
}
|
return {
|
||||||
return t;
|
...t,
|
||||||
}));
|
status: "pending" as TaskStatus,
|
||||||
|
error: undefined,
|
||||||
|
result: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearFinished = useCallback(() => {
|
const clearFinished = useCallback(() => {
|
||||||
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
setTasks((prev) =>
|
||||||
|
prev.filter((t) => t.status === "pending" || t.status === "running"),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleOpen = useCallback((id: string) => {
|
const toggleOpen = useCallback((id: string) => {
|
||||||
setOpenDetails(prev => {
|
setOpenDetails((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
|
const extractedCount = useMemo(
|
||||||
|
() => extractDouyinLinks(input).length,
|
||||||
|
[input],
|
||||||
|
);
|
||||||
|
|
||||||
const formatDuration = (startTime?: number, endTime?: number) => {
|
const formatDuration = (startTime?: number, endTime?: number) => {
|
||||||
if (!startTime) return "";
|
if (!startTime) return "";
|
||||||
@ -173,11 +236,31 @@ export default function TasksPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
||||||
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
const base =
|
||||||
if (status === 'running') return <span className={`${base} bg-indigo-500/15 text-indigo-300`}><Loader2 className="h-3.5 w-3.5 animate-spin"/> 进行中</span>;
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||||||
if (status === 'pending') return <span className={`${base} bg-yellow-500/15 text-yellow-300`}><Clock className="h-3.5 w-3.5"/> 待开始</span>;
|
if (status === "running")
|
||||||
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> 完成</span>;
|
return (
|
||||||
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> 失败</span>;
|
<span className={`${base} bg-indigo-500/15 text-indigo-300`}>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 进行中
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status === "pending")
|
||||||
|
return (
|
||||||
|
<span className={`${base} bg-yellow-500/15 text-yellow-300`}>
|
||||||
|
<Clock className="h-3.5 w-3.5" /> 待开始
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status === "success")
|
||||||
|
return (
|
||||||
|
<span className={`${base} bg-emerald-500/15 text-emerald-300`}>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" /> 完成
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className={`${base} bg-red-500/15 text-red-300`}>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" /> 失败
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -192,11 +275,16 @@ export default function TasksPage() {
|
|||||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||||
抖音采集任务
|
抖音采集任务
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
|
<p className="mt-2 text-sm text-neutral-400">
|
||||||
|
粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 输入卡片 */}
|
{/* 输入卡片 */}
|
||||||
<form onSubmit={handleSubmit} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
|
||||||
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
|
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
|
||||||
<Link2 className="h-5 w-5 text-neutral-400" />
|
<Link2 className="h-5 w-5 text-neutral-400" />
|
||||||
@ -210,18 +298,31 @@ export default function TasksPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||||||
<span>已提取 {extractedCount} 个短链</span>
|
<span>已提取 {extractedCount} 个短链</span>
|
||||||
<span className="hidden sm:inline">快捷键:Ctrl / Cmd + Enter 提交</span>
|
<span className="hidden sm:inline">
|
||||||
|
快捷键:Ctrl / Cmd + Enter 提交
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" /> 添加任务
|
<Plus className="h-4 w-4" /> 添加任务
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={handlePasteAndAdd} className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasteAndAdd}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
|
||||||
|
>
|
||||||
<Clipboard className="h-4 w-4" /> 粘贴并添加
|
<Clipboard className="h-4 w-4" /> 粘贴并添加
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFinished}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10"
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" /> 清除已完成
|
<Trash2 className="h-4 w-4" /> 清除已完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -231,64 +332,91 @@ export default function TasksPage() {
|
|||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-medium">进行中的任务</h2>
|
<h2 className="text-lg font-medium">进行中的任务</h2>
|
||||||
<span className="text-xs text-neutral-400">共 {tasks.length} 个</span>
|
<span className="text-xs text-neutral-400">
|
||||||
|
共 {tasks.length} 个
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{tasks.map((t) => {
|
{tasks.map((t) => {
|
||||||
const isOpen = openDetails.has(t.id);
|
const isOpen = openDetails.has(t.id);
|
||||||
return (
|
return (
|
||||||
<li key={t.id} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusBadge status={t.status} />
|
<StatusBadge status={t.status} />
|
||||||
{t.status === 'running' && (
|
{t.status === "running" && (
|
||||||
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span>
|
<span className="text-xs text-neutral-400">
|
||||||
|
已耗时 {formatDuration(t.startedAt)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{t.status === 'success' && (
|
{t.status === "success" && (
|
||||||
<span className="text-xs text-neutral-400">用时 {formatDuration(t.startedAt, t.finishedAt)}</span>
|
<span className="text-xs text-neutral-400">
|
||||||
|
用时 {formatDuration(t.startedAt, t.finishedAt)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
||||||
<span className="truncate" title={t.url}>{t.url}</span>
|
<span className="truncate" title={t.url}>
|
||||||
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer">
|
{t.url}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="shrink-0 text-neutral-400 hover:text-neutral-200"
|
||||||
|
href={t.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{t.status === 'success' && t.result?.data?.aweme_id && (
|
{t.status === "success" && t.result?.data?.aweme_id && (
|
||||||
<a
|
<a
|
||||||
href={`/aweme/${t.result.data.aweme_id}`}
|
href={`/aweme/${t.result.data.aweme_id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
|
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5"/> 查看作品
|
<ExternalLink className="h-3.5 w-3.5" /> 查看作品
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{t.status === 'error' && (
|
{t.status === "error" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => retryTask(t.id)}
|
onClick={() => retryTask(t.id)}
|
||||||
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
|
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
|
||||||
>
|
>
|
||||||
<PlayCircle className="h-3.5 w-3.5"/> 重试
|
<PlayCircle className="h-3.5 w-3.5" /> 重试
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{t.status === 'running' && (
|
{t.status === "running" && (
|
||||||
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600">
|
<button
|
||||||
<Square className="h-3.5 w-3.5"/> 取消
|
onClick={() => cancelTask(t.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<Square className="h-3.5 w-3.5" /> 取消
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => toggleOpen(t.id)} className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700">
|
<button
|
||||||
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'}
|
onClick={() => toggleOpen(t.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
)}{" "}
|
||||||
|
{isOpen ? "收起" : "详情"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
{t.status === 'running' && (
|
{t.status === "running" && (
|
||||||
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
|
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
|
||||||
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
|
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
|
||||||
</div>
|
</div>
|
||||||
@ -296,13 +424,17 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||||||
{t.status === 'error' && t.error && (
|
{t.status === "error" && t.error && (
|
||||||
<div className="mb-3 space-y-2">
|
<div className="mb-3 space-y-2">
|
||||||
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
|
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400"/>
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-red-200">爬取失败</div>
|
<div className="text-sm font-medium text-red-200">
|
||||||
<div className="mt-1 text-xs text-red-300/90">{t.error}</div>
|
爬取失败
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-red-300/90">
|
||||||
|
{t.error}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-400">
|
<div className="text-xs text-neutral-400">
|
||||||
@ -315,34 +447,58 @@ export default function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof t.result !== 'undefined' && (
|
{typeof t.result !== "undefined" && (
|
||||||
<div>
|
<div>
|
||||||
{t.result?.data?.aweme && (
|
{t.result?.data?.aweme && (
|
||||||
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
|
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-400"/>
|
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||||
<span className="font-medium text-emerald-200">作品信息</span>
|
<span className="font-medium text-emerald-200">
|
||||||
|
作品信息
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-neutral-300">
|
<div className="space-y-1 text-neutral-300">
|
||||||
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div>
|
<div>
|
||||||
<div><span className="text-neutral-400">描述:</span> {t.result.data.aweme.desc || '(无)'}</div>
|
<span className="text-neutral-400">ID:</span>{" "}
|
||||||
|
{t.result.data.aweme.aweme_id}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-neutral-400">
|
||||||
|
描述:
|
||||||
|
</span>{" "}
|
||||||
|
{t.result.data.aweme.desc || "(无)"}
|
||||||
|
</div>
|
||||||
{t.result.data.aweme.author && (
|
{t.result.data.aweme.author && (
|
||||||
<div><span className="text-neutral-400">作者:</span> {t.result.data.aweme.author.nickname}</div>
|
<div>
|
||||||
|
<span className="text-neutral-400">
|
||||||
|
作者:
|
||||||
|
</span>{" "}
|
||||||
|
{t.result.data.aweme.author.nickname}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
||||||
查看完整响应数据 <span className="group-open:hidden">▶</span><span className="hidden group-open:inline">▼</span>
|
查看完整响应数据{" "}
|
||||||
|
<span className="group-open:hidden">▶</span>
|
||||||
|
<span className="hidden group-open:inline">
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">
|
||||||
|
{JSON.stringify(t.result, null, 2)}
|
||||||
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
{typeof t.result === "undefined" &&
|
||||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
t.status !== "error" && (
|
||||||
)}
|
<div className="text-xs text-neutral-400">
|
||||||
|
暂无输出
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
export type FeedItem =
|
export type FeedItem = (
|
||||||
| ({
|
| {
|
||||||
type: "video";
|
type: "video";
|
||||||
video_url: string;
|
video_url: string;
|
||||||
} | {
|
}
|
||||||
|
| {
|
||||||
type: "image";
|
type: "image";
|
||||||
}) & {
|
}
|
||||||
likes: number;
|
) & {
|
||||||
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
likes: number;
|
||||||
aweme_id: string;
|
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
||||||
created_at: Date | string;
|
aweme_id: string;
|
||||||
desc: string;
|
created_at: Date | string;
|
||||||
cover_url: string | null;
|
desc: string;
|
||||||
width?: number | null;
|
cover_url: string | null;
|
||||||
height?: number | null;
|
width?: number | null;
|
||||||
};
|
height?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
items: FeedItem[];
|
items: FeedItem[];
|
||||||
|
|||||||
25
bun.lock
25
bun.lock
@ -8,13 +8,14 @@
|
|||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "15.5.6",
|
"next": "15.5.7",
|
||||||
"openai": "^6.7.0",
|
"openai": "^6.7.0",
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
"playwright-extra": "^4.3.6",
|
"playwright-extra": "^4.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"undici": "^7.16.0",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -113,23 +114,23 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="],
|
"@next/env": ["@next/env@15.5.7", "https://registry.npmmirror.com/@next/env/-/env-15.5.7.tgz", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
|
||||||
|
|
||||||
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
|
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
|
||||||
|
|
||||||
@ -503,7 +504,7 @@
|
|||||||
|
|
||||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||||
|
|
||||||
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="],
|
"next": ["next@15.5.7", "https://registry.npmmirror.com/next/-/next-15.5.7.tgz", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="],
|
||||||
|
|
||||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
@ -631,6 +632,8 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici": ["undici@7.16.0", "https://registry.npmmirror.com/undici/-/undici-7.16.0.tgz", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
// scripts/fix-asset-urls.ts
|
// scripts/fix-asset-urls.ts
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const FROM = 'douyin-archive/';
|
const FROM = "douyin-archive/";
|
||||||
const TO = '';
|
const TO = "";
|
||||||
|
|
||||||
function escapeForPgRegex(s: string) {
|
function escapeForPgRegex(s: string) {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
|
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
|
||||||
const dryRun = false; // true: 只统计,不修改
|
const dryRun = false; // true: 只统计,不修改
|
||||||
@ -121,12 +121,14 @@ async function main() {
|
|||||||
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
|
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
console.log('Sample after update:', sample);
|
console.log("Sample after update:", sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main()
|
||||||
console.error(e);
|
.catch((e) => {
|
||||||
process.exit(1);
|
console.error(e);
|
||||||
}).finally(async () => {
|
process.exit(1);
|
||||||
await prisma.$disconnect();
|
})
|
||||||
});
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|||||||
4
global.d.ts
vendored
4
global.d.ts
vendored
@ -1,9 +1,9 @@
|
|||||||
declare module '*.md' {
|
declare module "*.md" {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.txt' {
|
declare module "*.txt" {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
// lib/json.ts
|
// lib/json.ts
|
||||||
export function json(data: unknown, init?: ResponseInit) {
|
export function json(data: unknown, init?: ResponseInit) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify(data, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
|
||||||
{
|
{
|
||||||
...init,
|
...init,
|
||||||
headers: { 'content-type': 'application/json; charset=utf-8', ...(init?.headers || {}) },
|
headers: {
|
||||||
}
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
downloadFile,
|
downloadFile,
|
||||||
fileExists,
|
fileExists,
|
||||||
getFileInfo,
|
getFileInfo,
|
||||||
} from './minio';
|
} from "./minio";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 1. 上传文件示例
|
// 1. 上传文件示例
|
||||||
@ -25,15 +25,15 @@ import {
|
|||||||
*/
|
*/
|
||||||
async function uploadAvatar(file: File, userId: string) {
|
async function uploadAvatar(file: File, userId: string) {
|
||||||
// 验证文件类型
|
// 验证文件类型
|
||||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||||
if (!validateFileType(file.name, allowedTypes)) {
|
if (!validateFileType(file.name, allowedTypes)) {
|
||||||
throw new Error('不支持的图片格式');
|
throw new Error("不支持的图片格式");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小(5MB)
|
// 验证文件大小(5MB)
|
||||||
const maxSize = 5 * 1024 * 1024;
|
const maxSize = 5 * 1024 * 1024;
|
||||||
if (!validateFileSize(file.size, maxSize)) {
|
if (!validateFileSize(file.size, maxSize)) {
|
||||||
throw new Error('文件大小超过限制(最大5MB)');
|
throw new Error("文件大小超过限制(最大5MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一文件名,存储在 avatars 目录下
|
// 生成唯一文件名,存储在 avatars 目录下
|
||||||
@ -41,8 +41,8 @@ async function uploadAvatar(file: File, userId: string) {
|
|||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const url = await uploadFile(file, path, {
|
const url = await uploadFile(file, path, {
|
||||||
'Content-Type': file.type,
|
"Content-Type": file.type,
|
||||||
'User-Id': userId,
|
"User-Id": userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { url, path };
|
return { url, path };
|
||||||
@ -52,28 +52,28 @@ async function uploadAvatar(file: File, userId: string) {
|
|||||||
* 上传文章封面图
|
* 上传文章封面图
|
||||||
*/
|
*/
|
||||||
async function uploadPostCover(file: File, postId: string) {
|
async function uploadPostCover(file: File, postId: string) {
|
||||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp'];
|
const allowedTypes = ["jpg", "jpeg", "png", "webp"];
|
||||||
if (!validateFileType(file.name, allowedTypes)) {
|
if (!validateFileType(file.name, allowedTypes)) {
|
||||||
throw new Error('不支持的图片格式');
|
throw new Error("不支持的图片格式");
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
if (!validateFileSize(file.size, maxSize)) {
|
if (!validateFileSize(file.size, maxSize)) {
|
||||||
throw new Error('文件大小超过限制(最大10MB)');
|
throw new Error("文件大小超过限制(最大10MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按日期组织文件
|
// 按日期组织文件
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
||||||
const path = generateUniqueFileName(
|
const path = generateUniqueFileName(
|
||||||
file.name,
|
file.name,
|
||||||
`posts/${year}/${month}/covers`
|
`posts/${year}/${month}/covers`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = await uploadFile(file, path);
|
const url = await uploadFile(file, path);
|
||||||
|
|
||||||
return { url, path };
|
return { url, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,27 +81,27 @@ async function uploadPostCover(file: File, postId: string) {
|
|||||||
* 上传文章内容中的图片
|
* 上传文章内容中的图片
|
||||||
*/
|
*/
|
||||||
async function uploadPostImage(file: File, postId: string) {
|
async function uploadPostImage(file: File, postId: string) {
|
||||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||||
if (!validateFileType(file.name, allowedTypes)) {
|
if (!validateFileType(file.name, allowedTypes)) {
|
||||||
throw new Error('不支持的图片格式');
|
throw new Error("不支持的图片格式");
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||||
if (!validateFileSize(file.size, maxSize)) {
|
if (!validateFileSize(file.size, maxSize)) {
|
||||||
throw new Error('文件大小超过限制(最大5MB)');
|
throw new Error("文件大小超过限制(最大5MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
||||||
const path = generateUniqueFileName(
|
const path = generateUniqueFileName(
|
||||||
file.name,
|
file.name,
|
||||||
`posts/${year}/${month}/images`
|
`posts/${year}/${month}/images`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = await uploadFile(file, path);
|
const url = await uploadFile(file, path);
|
||||||
|
|
||||||
return { url, path };
|
return { url, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,9 +126,9 @@ function getPublicFileUrl(path: string) {
|
|||||||
async function deleteAvatar(avatarPath: string) {
|
async function deleteAvatar(avatarPath: string) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(avatarPath);
|
await deleteFile(avatarPath);
|
||||||
console.log('头像删除成功');
|
console.log("头像删除成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除头像失败:', error);
|
console.error("删除头像失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -140,22 +140,22 @@ async function deletePostImages(postId: string) {
|
|||||||
try {
|
try {
|
||||||
// 列出文章相关的所有图片
|
// 列出文章相关的所有图片
|
||||||
const files = await listFiles(`posts/`, true);
|
const files = await listFiles(`posts/`, true);
|
||||||
|
|
||||||
// 过滤出该文章的图片(根据实际情况调整逻辑)
|
// 过滤出该文章的图片(根据实际情况调整逻辑)
|
||||||
const postFiles = files.filter(file =>
|
const postFiles = files.filter((file) => file.name?.includes(postId));
|
||||||
file.name?.includes(postId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 批量删除
|
// 批量删除
|
||||||
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name);
|
const paths = postFiles
|
||||||
|
.map((f) => f.name)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
|
||||||
if (paths.length > 0) {
|
if (paths.length > 0) {
|
||||||
const { deleteFiles } = await import('./minio');
|
const { deleteFiles } = await import("./minio");
|
||||||
await deleteFiles(paths);
|
await deleteFiles(paths);
|
||||||
console.log(`删除了 ${paths.length} 个文件`);
|
console.log(`删除了 ${paths.length} 个文件`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除文章图片失败:', error);
|
console.error("删除文章图片失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -170,15 +170,15 @@ async function deletePostImages(postId: string) {
|
|||||||
async function getUserAvatars(userId: string) {
|
async function getUserAvatars(userId: string) {
|
||||||
try {
|
try {
|
||||||
const files = await listFiles(`avatars/${userId}/`, false);
|
const files = await listFiles(`avatars/${userId}/`, false);
|
||||||
|
|
||||||
return files.map(file => ({
|
return files.map((file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: file.lastModified,
|
lastModified: file.lastModified,
|
||||||
url: file.name ? getFileUrl(file.name) : null,
|
url: file.name ? getFileUrl(file.name) : null,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取用户头像列表失败:', error);
|
console.error("获取用户头像列表失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,16 +188,16 @@ async function getUserAvatars(userId: string) {
|
|||||||
*/
|
*/
|
||||||
async function getPostCoversByMonth(year: number, month: number) {
|
async function getPostCoversByMonth(year: number, month: number) {
|
||||||
try {
|
try {
|
||||||
const monthStr = String(month).padStart(2, '0');
|
const monthStr = String(month).padStart(2, "0");
|
||||||
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
|
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
|
||||||
|
|
||||||
return files.map(file => ({
|
return files.map((file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
url: file.name ? getFileUrl(file.name) : null,
|
url: file.name ? getFileUrl(file.name) : null,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取封面列表失败:', error);
|
console.error("获取封面列表失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -223,15 +223,15 @@ async function checkAvatarExists(avatarPath: string): Promise<boolean> {
|
|||||||
async function getFileDetails(path: string) {
|
async function getFileDetails(path: string) {
|
||||||
try {
|
try {
|
||||||
const info = await getFileInfo(path);
|
const info = await getFileInfo(path);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
size: info.size,
|
size: info.size,
|
||||||
lastModified: info.lastModified,
|
lastModified: info.lastModified,
|
||||||
etag: info.etag,
|
etag: info.etag,
|
||||||
contentType: info.metaData?.['content-type'],
|
contentType: info.metaData?.["content-type"],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取文件信息失败:', error);
|
console.error("获取文件信息失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,7 +247,7 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
|||||||
try {
|
try {
|
||||||
return await downloadFile(path);
|
return await downloadFile(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载文件失败:', error);
|
console.error("下载文件失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -258,42 +258,42 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js API Route 示例:上传文件
|
* Next.js API Route 示例:上传文件
|
||||||
*
|
*
|
||||||
* 使用方法:
|
* 使用方法:
|
||||||
*
|
*
|
||||||
* // app/api/upload/route.ts
|
* // app/api/upload/route.ts
|
||||||
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||||
*
|
*
|
||||||
* export async function POST(request: Request) {
|
* export async function POST(request: Request) {
|
||||||
* const formData = await request.formData();
|
* const formData = await request.formData();
|
||||||
* const file = formData.get('file') as File;
|
* const file = formData.get('file') as File;
|
||||||
*
|
*
|
||||||
* if (!file) {
|
* if (!file) {
|
||||||
* return Response.json({ error: '没有文件' }, { status: 400 });
|
* return Response.json({ error: '没有文件' }, { status: 400 });
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* const path = generateUniqueFileName(file.name, 'uploads');
|
* const path = generateUniqueFileName(file.name, 'uploads');
|
||||||
* const url = await uploadFile(file, path);
|
* const url = await uploadFile(file, path);
|
||||||
*
|
*
|
||||||
* return Response.json({ url, path });
|
* return Response.json({ url, path });
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js API Route 示例:删除文件
|
* Next.js API Route 示例:删除文件
|
||||||
*
|
*
|
||||||
* // app/api/delete/route.ts
|
* // app/api/delete/route.ts
|
||||||
* import { deleteFile } from '@/lib/minio';
|
* import { deleteFile } from '@/lib/minio';
|
||||||
*
|
*
|
||||||
* export async function DELETE(request: Request) {
|
* export async function DELETE(request: Request) {
|
||||||
* const { path } = await request.json();
|
* const { path } = await request.json();
|
||||||
*
|
*
|
||||||
* if (!path) {
|
* if (!path) {
|
||||||
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
|
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* await deleteFile(path);
|
* await deleteFile(path);
|
||||||
*
|
*
|
||||||
* return Response.json({ success: true });
|
* return Response.json({ success: true });
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|||||||
119
lib/minio.ts
119
lib/minio.ts
@ -1,22 +1,22 @@
|
|||||||
import * as Minio from 'minio';
|
import * as Minio from "minio";
|
||||||
|
|
||||||
// MinIO 客户端配置
|
// MinIO 客户端配置
|
||||||
const useSSL = process.env.MINIO_USE_SSL === 'true';
|
const useSSL = process.env.MINIO_USE_SSL === "true";
|
||||||
const port = Number(process.env.MINIO_PORT) || 9000;
|
const port = Number(process.env.MINIO_PORT) || 9000;
|
||||||
|
|
||||||
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
||||||
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
||||||
|
|
||||||
const minioClient = new Minio.Client({
|
const minioClient = new Minio.Client({
|
||||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
endPoint: process.env.MINIO_ENDPOINT || "localhost",
|
||||||
...(shouldOmitPort ? {} : { port }),
|
...(shouldOmitPort ? {} : { port }),
|
||||||
useSSL,
|
useSSL,
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
secretKey: process.env.MINIO_SECRET_KEY || "",
|
||||||
pathStyle: true, // 使用路径风格,对反向代理更友好
|
pathStyle: true, // 使用路径风格,对反向代理更友好
|
||||||
});
|
});
|
||||||
|
|
||||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page';
|
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "home-page";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 MinIO Bucket(确保 bucket 存在)
|
* 初始化 MinIO Bucket(确保 bucket 存在)
|
||||||
@ -25,41 +25,39 @@ export async function initBucket(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1');
|
await minioClient.makeBucket(BUCKET_NAME, "us-east-1");
|
||||||
console.log(`Bucket ${BUCKET_NAME} created successfully`);
|
console.log(`Bucket ${BUCKET_NAME} created successfully`);
|
||||||
}
|
}
|
||||||
// 设置公共读取策略(可选)
|
// 设置公共读取策略(可选)
|
||||||
const policy = {
|
const policy = {
|
||||||
Version: '2012-10-17',
|
Version: "2012-10-17",
|
||||||
Statement: [
|
Statement: [
|
||||||
{
|
{
|
||||||
Effect: 'Allow',
|
Effect: "Allow",
|
||||||
Principal: { AWS: ['*'] },
|
Principal: { AWS: ["*"] },
|
||||||
Action: ['s3:GetObject'],
|
Action: ["s3:GetObject"],
|
||||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing bucket:', error);
|
console.error("Error initializing bucket:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到 MinIO
|
* 上传文件到 MinIO
|
||||||
* @param file - File 对象或 Buffer
|
* @param file - File 对象或 Buffer
|
||||||
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
||||||
* @param metadata - 可选的元数据
|
* @param metadata - 可选的元数据
|
||||||
* @returns 文件 path = args.path
|
* @returns 文件 path = args.path
|
||||||
*/
|
*/
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
file: File | Buffer,
|
file: File | Buffer,
|
||||||
path: string,
|
path: string,
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
await initBucket();
|
await initBucket();
|
||||||
@ -69,27 +67,32 @@ export async function uploadFile(
|
|||||||
|
|
||||||
if (file instanceof File) {
|
if (file instanceof File) {
|
||||||
buffer = Buffer.from(await file.arrayBuffer());
|
buffer = Buffer.from(await file.arrayBuffer());
|
||||||
contentType = file.type || 'application/octet-stream';
|
contentType = file.type || "application/octet-stream";
|
||||||
} else {
|
} else {
|
||||||
buffer = file;
|
buffer = file;
|
||||||
contentType = metadata?.['Content-Type'] || 'application/octet-stream';
|
contentType = metadata?.["Content-Type"] || "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaData = {
|
const metaData = {
|
||||||
'Content-Type': contentType,
|
"Content-Type": contentType,
|
||||||
...metadata,
|
...metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData);
|
await minioClient.putObject(
|
||||||
|
BUCKET_NAME,
|
||||||
|
path,
|
||||||
|
buffer,
|
||||||
|
buffer.length,
|
||||||
|
metaData,
|
||||||
|
);
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading file:', error);
|
console.error("Error uploading file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件的公共访问URL
|
* 获取文件的公共访问URL
|
||||||
* @param path - 文件路径
|
* @param path - 文件路径
|
||||||
@ -112,12 +115,12 @@ export async function downloadFile(path: string): Promise<Buffer> {
|
|||||||
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
stream.on('data', (chunk) => chunks.push(chunk));
|
stream.on("data", (chunk) => chunks.push(chunk));
|
||||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading file:', error);
|
console.error("Error downloading file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,11 +130,13 @@ export async function downloadFile(path: string): Promise<Buffer> {
|
|||||||
* @param path - 文件路径
|
* @param path - 文件路径
|
||||||
* @returns 文件流
|
* @returns 文件流
|
||||||
*/
|
*/
|
||||||
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> {
|
export async function getFileStream(
|
||||||
|
path: string,
|
||||||
|
): Promise<NodeJS.ReadableStream> {
|
||||||
try {
|
try {
|
||||||
return await minioClient.getObject(BUCKET_NAME, path);
|
return await minioClient.getObject(BUCKET_NAME, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting file stream:', error);
|
console.error("Error getting file stream:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,7 +149,7 @@ export async function deleteFile(path: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await minioClient.removeObject(BUCKET_NAME, path);
|
await minioClient.removeObject(BUCKET_NAME, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting file:', error);
|
console.error("Error deleting file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,7 +162,7 @@ export async function deleteFiles(paths: string[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await minioClient.removeObjects(BUCKET_NAME, paths);
|
await minioClient.removeObjects(BUCKET_NAME, paths);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting files:', error);
|
console.error("Error deleting files:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,7 +190,7 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
|||||||
try {
|
try {
|
||||||
return await minioClient.statObject(BUCKET_NAME, path);
|
return await minioClient.statObject(BUCKET_NAME, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting file info:', error);
|
console.error("Error getting file info:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,24 +202,27 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
|||||||
* @returns 文件列表
|
* @returns 文件列表
|
||||||
*/
|
*/
|
||||||
export async function listFiles(
|
export async function listFiles(
|
||||||
prefix: string = '',
|
prefix: string = "",
|
||||||
recursive: boolean = false
|
recursive: boolean = false,
|
||||||
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
||||||
try {
|
try {
|
||||||
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
||||||
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
stream.on('data', (obj) => {
|
stream.on("data", (obj) => {
|
||||||
if (obj.name) {
|
if (obj.name) {
|
||||||
files.push({ endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string });
|
files.push({
|
||||||
|
endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`,
|
||||||
|
...obj,
|
||||||
|
} as Minio.BucketItem & { endpoint: string });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
stream.on('end', () => resolve(files));
|
stream.on("end", () => resolve(files));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error listing files:', error);
|
console.error("Error listing files:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,17 +232,20 @@ export async function listFiles(
|
|||||||
* @param sourcePath - 源文件路径
|
* @param sourcePath - 源文件路径
|
||||||
* @param destPath - 目标文件路径
|
* @param destPath - 目标文件路径
|
||||||
*/
|
*/
|
||||||
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
|
export async function copyFile(
|
||||||
|
sourcePath: string,
|
||||||
|
destPath: string,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const conds = new Minio.CopyConditions();
|
const conds = new Minio.CopyConditions();
|
||||||
await minioClient.copyObject(
|
await minioClient.copyObject(
|
||||||
BUCKET_NAME,
|
BUCKET_NAME,
|
||||||
destPath,
|
destPath,
|
||||||
`/${BUCKET_NAME}/${sourcePath}`,
|
`/${BUCKET_NAME}/${sourcePath}`,
|
||||||
conds
|
conds,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying file:', error);
|
console.error("Error copying file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,15 +256,20 @@ export async function copyFile(sourcePath: string, destPath: string): Promise<vo
|
|||||||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||||||
* @returns 带路径的唯一文件名
|
* @returns 带路径的唯一文件名
|
||||||
*/
|
*/
|
||||||
export function generateUniqueFileName(originalName: string, prefix: string = ''): string {
|
export function generateUniqueFileName(
|
||||||
|
originalName: string,
|
||||||
|
prefix: string = "",
|
||||||
|
): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
const ext = originalName.split('.').pop();
|
const ext = originalName.split(".").pop();
|
||||||
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_');
|
const nameWithoutExt = originalName
|
||||||
|
.replace(`.${ext}`, "")
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, "_");
|
||||||
|
|
||||||
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
|
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
|
||||||
|
|
||||||
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName;
|
return prefix ? `${prefix.replace(/\/$/, "")}/${fileName}` : fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -262,7 +278,7 @@ export function generateUniqueFileName(originalName: string, prefix: string = ''
|
|||||||
* @returns 扩展名(不含点)
|
* @returns 扩展名(不含点)
|
||||||
*/
|
*/
|
||||||
export function getFileExtension(filename: string): string {
|
export function getFileExtension(filename: string): string {
|
||||||
return filename.split('.').pop() || '';
|
return filename.split(".").pop() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -271,9 +287,12 @@ export function getFileExtension(filename: string): string {
|
|||||||
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
|
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
|
||||||
* @returns 是否为允许的类型
|
* @returns 是否为允许的类型
|
||||||
*/
|
*/
|
||||||
export function validateFileType(filename: string, allowedTypes: string[]): boolean {
|
export function validateFileType(
|
||||||
|
filename: string,
|
||||||
|
allowedTypes: string[],
|
||||||
|
): boolean {
|
||||||
const ext = getFileExtension(filename).toLowerCase();
|
const ext = getFileExtension(filename).toLowerCase();
|
||||||
return allowedTypes.map(t => t.toLowerCase()).includes(ext);
|
return allowedTypes.map((t) => t.toLowerCase()).includes(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -292,10 +311,10 @@ export function validateFileSize(size: number, maxSize: number): boolean {
|
|||||||
* @returns 格式化后的文件大小
|
* @returns 格式化后的文件大小
|
||||||
*/
|
*/
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return "0 B";
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined
|
prisma: PrismaClient | undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
|
|||||||
@ -2,27 +2,29 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
'playwright-extra',
|
"playwright-extra",
|
||||||
'puppeteer-extra-plugin-stealth',
|
"puppeteer-extra-plugin-stealth",
|
||||||
'puppeteer-extra-plugin',
|
"puppeteer-extra-plugin",
|
||||||
], webpack: (config) => {
|
],
|
||||||
|
webpack: (config) => {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.(md|txt)$/i,
|
test: /\.(md|txt)$/i,
|
||||||
type: 'asset/source', // 让这些文件作为纯文本注入
|
type: "asset/source", // 让这些文件作为纯文本注入
|
||||||
});
|
});
|
||||||
return config;
|
return config;
|
||||||
}, turbopack: {
|
},
|
||||||
|
turbopack: {
|
||||||
rules: {
|
rules: {
|
||||||
'*.md': { loaders: ['raw-loader'], as: '*.js' },
|
"*.md": { loaders: ["raw-loader"], as: "*.js" },
|
||||||
'*.txt': { loaders: ['raw-loader'], as: '*.js' },
|
"*.txt": { loaders: ["raw-loader"], as: "*.js" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/* config options here */
|
/* config options here */
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: "https",
|
||||||
hostname: 's3l.xn--876a.net',
|
hostname: "s3l.xn--876a.net",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "15.5.6",
|
"next": "15.5.7",
|
||||||
"openai": "^6.7.0",
|
"openai": "^6.7.0",
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
"playwright-extra": "^4.3.6",
|
"playwright-extra": "^4.3.6",
|
||||||
|
|||||||
@ -1,37 +1,39 @@
|
|||||||
const path = require('path');
|
const path = require("path");
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require("dotenv");
|
||||||
|
|
||||||
const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? '1', 10) || 1;
|
const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? "1", 10) || 1;
|
||||||
const { parsed: envFromFile = {} } = dotenv.config({ path: path.join(__dirname, '.env') });
|
const { parsed: envFromFile = {} } = dotenv.config({
|
||||||
|
path: path.join(__dirname, ".env"),
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "DouyinArchive",
|
name: "DouyinArchive",
|
||||||
script: 'npm',
|
script: "npm",
|
||||||
args: 'run start',
|
args: "run start",
|
||||||
cwd: __dirname,
|
cwd: __dirname,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
restart_delay: 4000,
|
restart_delay: 4000,
|
||||||
kill_timeout: 5000,
|
kill_timeout: 5000,
|
||||||
instances,
|
instances,
|
||||||
exec_mode: instances > 1 ? 'cluster' : 'fork',
|
exec_mode: instances > 1 ? "cluster" : "fork",
|
||||||
// 注意:不要在生产环境 watch,否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭
|
// 注意:不要在生产环境 watch,否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭
|
||||||
watch: false,
|
watch: false,
|
||||||
ignore_watch: ['.next', '.turbo', 'generated', 'node_modules', '.git'],
|
ignore_watch: [".next", ".turbo", "generated", "node_modules", ".git"],
|
||||||
env: {
|
env: {
|
||||||
// 明确开发环境可选项(如需)
|
// 明确开发环境可选项(如需)
|
||||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
NODE_ENV: process.env.NODE_ENV || "development",
|
||||||
...envFromFile,
|
...envFromFile,
|
||||||
},
|
},
|
||||||
env_production: {
|
env_production: {
|
||||||
// 关键:确保应用进程中的 NODE_ENV=production,从而禁用 Next.js 的开发特性
|
// 关键:确保应用进程中的 NODE_ENV=production,从而禁用 Next.js 的开发特性
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: "production",
|
||||||
// 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务
|
// 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务
|
||||||
WEB_CONCURRENCY: '1',
|
WEB_CONCURRENCY: "1",
|
||||||
...envFromFile,
|
...envFromFile,
|
||||||
},
|
},
|
||||||
time: true
|
time: true,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
test.ts
2
test.ts
@ -1,4 +1,4 @@
|
|||||||
import { createWriteStream, writeFileSync } from "node:fs";
|
import { createWriteStream, writeFileSync } from "node:fs";
|
||||||
import { initBucket } from "./lib/minio";
|
import { initBucket } from "./lib/minio";
|
||||||
|
|
||||||
initBucket()
|
initBucket();
|
||||||
|
|||||||
@ -22,6 +22,12 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/aweme/[awemeId]/types.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"app/aweme/[awemeId]/types.ts"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user