init
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limitParam = searchParams.get('limit');
|
||||
const beforeParam = searchParams.get('before');
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60
|
||||
const before = beforeParam ? new Date(beforeParam) : null;
|
||||
|
||||
// fetch chunk from both tables
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const merged: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: 'video' as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: v.video_url,
|
||||
cover_url: v.cover_url ?? null,
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
||||
likes: Number(v.digg_count),
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: 'image' as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: p.images?.[0]?.url ?? null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at as any) - +new Date(a.created_at as any))
|
||||
.slice(0, limit);
|
||||
|
||||
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null;
|
||||
const payload: FeedResponse = { items: merged, nextCursor };
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pause,
|
||||
Play,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize,
|
||||
Minimize2,
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
|
||||
type User = { nickname: string; avatar_url: string | null };
|
||||
type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User };
|
||||
|
||||
type VideoData = {
|
||||
type: "video";
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
duration_ms?: number | null;
|
||||
video_url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
type ImageData = {
|
||||
type: "image";
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
images: { id: string; url: string; width?: number; height?: number }[];
|
||||
music_url?: string | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
const SEGMENT_MS = 5000; // 图文每段 5s
|
||||
|
||||
export default function AwemeDetailClient(props: { data: VideoData | ImageData }) {
|
||||
const { data } = props;
|
||||
const isVideo = data.type === "video";
|
||||
|
||||
// ====== 布局 & 评论 ======
|
||||
const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏)
|
||||
const comments = useMemo(() => data.comments ?? [], [data]);
|
||||
|
||||
// ====== 媒体引用 ======
|
||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// ====== 统一控制状态 ======
|
||||
const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [volume, setVolume] = useState(1); // 视频音量 / 图文BGM音量
|
||||
const [rate, setRate] = useState(1); // 仅视频使用
|
||||
const [progress, setProgress] = useState(0); // 0..1 总进度
|
||||
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
||||
|
||||
// ====== 图文专用(分段) ======
|
||||
const images = (data as any).images as ImageData["images"] | undefined;
|
||||
const totalSegments = images?.length ?? 0;
|
||||
const [idx, setIdx] = useState(0); // 当前图片索引
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 用 ref 解决“闪回”
|
||||
const segStartRef = useRef<number | null>(null); // 当前段开始时间戳
|
||||
const idxRef = useRef<number>(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [segProgress, setSegProgress] = useState(0); // 段内 0..1
|
||||
|
||||
useEffect(() => { idxRef.current = idx; }, [idx]);
|
||||
|
||||
// ====== 视频:进度/播放/倍速/音量 ======
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTime = () => {
|
||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||
setProgress(v.currentTime / v.duration);
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("loadedmetadata", onTime);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("loadedmetadata", onTime);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
};
|
||||
}, [isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, isVideo]);
|
||||
|
||||
// ====== 图文:BGM & 初次自动播放尝试 ======
|
||||
useEffect(() => {
|
||||
if (isVideo) return;
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.volume = volume;
|
||||
if (isPlaying) {
|
||||
el.play().catch(() => {/* 被策略阻止无妨,用户点播放即可 */ });
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [isVideo]); // 初次挂载
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideo) return;
|
||||
const el = audioRef.current;
|
||||
if (el) el.volume = volume;
|
||||
}, [volume, isVideo]);
|
||||
|
||||
// ====== 全屏状态监听 ======
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||
}, []);
|
||||
|
||||
// ====== 图文:自动切页(消除“闪回”)======
|
||||
useEffect(() => {
|
||||
if (isVideo || !images?.length) return;
|
||||
|
||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!images?.length) return;
|
||||
|
||||
let start = segStartRef.current!;
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
// 暂停时只更新 UI,不推进时间
|
||||
if (!isPlaying) {
|
||||
const elapsed = Math.max(0, ts - start);
|
||||
const localSeg = Math.min(1, elapsed / SEGMENT_MS);
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
// 前进时间:处理跨多段情况(极少见,但更稳妥)
|
||||
let elapsed = ts - start;
|
||||
while (elapsed >= SEGMENT_MS) {
|
||||
elapsed -= SEGMENT_MS;
|
||||
localIdx = (localIdx + 1) % images.length;
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
if (localIdx !== idxRef.current) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS));
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, [isVideo, images?.length, isPlaying]);
|
||||
|
||||
// 横向滚动同步 idx(且重置段起点)
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const i = Math.round(el.scrollLeft / el.clientWidth);
|
||||
if (i !== idxRef.current) {
|
||||
idxRef.current = i;
|
||||
setIdx(i);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
setProgress(images && images.length ? i / images.length : 0);
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [images?.length]);
|
||||
|
||||
// ====== 统一操作 ======
|
||||
const seekTo = (ratio: number) => {
|
||||
ratio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * ratio;
|
||||
return;
|
||||
}
|
||||
if (!images?.length) return;
|
||||
const total = images.length;
|
||||
const exact = ratio * total;
|
||||
const targetIdx = Math.min(total - 1, Math.floor(exact));
|
||||
const remainder = exact - targetIdx;
|
||||
|
||||
idxRef.current = targetIdx;
|
||||
setIdx(targetIdx);
|
||||
segStartRef.current = performance.now() - remainder * SEGMENT_MS;
|
||||
setSegProgress(remainder);
|
||||
setProgress((targetIdx + remainder) / total);
|
||||
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) await v.play();
|
||||
else v.pause();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true);
|
||||
try { await el?.play(); } catch { }
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
el?.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen().catch(() => { });
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
};
|
||||
|
||||
const prevImg = () => {
|
||||
if (!images?.length) return;
|
||||
const next = Math.max(0, idxRef.current - 1);
|
||||
idxRef.current = next;
|
||||
setIdx(next);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const nextImg = () => {
|
||||
if (!images?.length) return;
|
||||
const next = Math.min(images.length - 1, idxRef.current + 1);
|
||||
idxRef.current = next;
|
||||
setIdx(next);
|
||||
segStartRef.current = performance.now();
|
||||
setSegProgress(0);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// ====== 侧栏(横屏)/ 抽屉(竖屏)样式(Tailwind)
|
||||
const asideClasses = [
|
||||
"z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white",
|
||||
// 竖屏:bottom sheet,从下向上弹出
|
||||
"portrait:fixed portrait:inset-x-0 portrait:bottom-0 portrait:w-full portrait:h-[min(80vh,88dvh)]",
|
||||
"portrait:transition-transform portrait:duration-200 portrait:ease-out",
|
||||
open ? "portrait:translate-y-0" : "portrait:translate-y-full",
|
||||
"portrait:border-t portrait:border-white/10",
|
||||
// 横屏:并排分栏,宽度过渡
|
||||
"landscape:relative landscape:h-full landscape:overflow-hidden",
|
||||
"landscape:transition-[width] landscape:duration-200 landscape:ease-out",
|
||||
open
|
||||
? "landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
||||
: "landscape:w-0",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
||||
<div className="relative h-full landscape:flex landscape:flex-row">
|
||||
{/* 主媒体区域 */}
|
||||
<section ref={mediaContainerRef} className="relative h-screen landscape:flex-1">
|
||||
<div
|
||||
className="relative h-screen overflow-hidden"
|
||||
>
|
||||
{isVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={(data as VideoData).video_url}
|
||||
className={[
|
||||
// 旋转 0/180:充满容器盒子,用 object-contain;
|
||||
// 旋转 90/270:用中心定位 + 100vh/100vw + object-cover,保证铺满全屏
|
||||
rotation % 180 === 0
|
||||
? "absolute inset-0 h-full w-full object-contain bg-black/70"
|
||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-contain bg-black/70",
|
||||
].join(" ")}
|
||||
style={{
|
||||
transform:
|
||||
rotation % 180 === 0
|
||||
? `rotate(${rotation}deg)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="absolute inset-0 overflow-x-auto overflow-y-hidden snap-x snap-mandatory flex no-scrollbar"
|
||||
>
|
||||
{(data as ImageData).images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="snap-center shrink-0 w-full h-screen relative bg-black/70"
|
||||
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img.url} alt="image" className="absolute inset-0 w-full h-full object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 左右切图(多张时显示) */}
|
||||
{images && images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
className="absolute top-1/2 -translate-y-1/2 left-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
||||
onClick={prevImg}
|
||||
aria-label="上一张"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<button
|
||||
className="absolute top-1/2 -translate-y-1/2 right-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
||||
onClick={nextImg}
|
||||
aria-label="下一张"
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统一控制条:desc 在上、进度在下 */}
|
||||
<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="pointer-events-none flex items-center gap-2.5 mb-1">
|
||||
<img src={data.author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
||||
<span className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||
{data.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.desc ? (
|
||||
<div className="pointer-events-none">
|
||||
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||
{data.desc}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 进度条:图文=分段;视频=单段 */}
|
||||
{!isVideo && totalSegments > 0 ? (
|
||||
<div
|
||||
className="relative h-1.5 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo((e.clientX - rect.left) / rect.width);
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1.5 h-full">
|
||||
{Array.from({ length: totalSegments }).map((_, i) => {
|
||||
let fill = 0;
|
||||
if (i < idx) fill = 1;
|
||||
else if (i === idx) fill = segProgress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
aria-label={`第 ${i + 1} 段`}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative h-1 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo((e.clientX - rect.left) / rect.width);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="origin-left h-full bg-white"
|
||||
style={{ transform: `scaleX(${progress || 0})` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制按钮行 */}
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 倍速仅视频展示 */}
|
||||
{isVideo ? (
|
||||
<>
|
||||
<button
|
||||
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||
const i = steps.indexOf(rate);
|
||||
const next = steps[(i + 1) % steps.length];
|
||||
setRate(next);
|
||||
}}
|
||||
aria-label="切换倍速"
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
|
||||
{/* 旋转:向左/向右各 90° */}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => setRotation((r) => (r + 270) % 360)}
|
||||
aria-label="向左旋转 90 度"
|
||||
title="向左旋转 90 度"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => setVolume((v) => (v > 0 ? 0 : 1))}
|
||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||
>
|
||||
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-28 accent-white"
|
||||
aria-label="音量"
|
||||
/>
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={() => setRotation((r) => (r + 90) % 360)}
|
||||
aria-label="向右旋转 90 度"
|
||||
title="向右旋转 90 度"
|
||||
>
|
||||
<RotateCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label="切换全屏"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||
{!isVideo && "music_url" in data && data.music_url ? (
|
||||
<audio ref={audioRef} src={data.music_url ?? undefined} loop preload="metadata" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评论开关(竖屏:底部居中;横屏:右侧中部悬浮) */}
|
||||
<button
|
||||
className={[
|
||||
"z-10 grid place-items-center w-[54px] h-[54px] rounded-full ",
|
||||
"absolute right-4 top-2/3 -translate-y-1/2",
|
||||
].join(" ")}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="切换评论"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||
<MessageSquareText size={40} className="" />
|
||||
<span className="text-xl text-white">
|
||||
{comments.length > 0 ? `${comments.length}` : "暂无评论"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}
|
||||
<aside className={asideClasses}>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<button
|
||||
className="text-white/90 text-xs px-2 py-1 rounded-lg bg-white/15 border border-white/20"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<div className="text-white font-semibold">
|
||||
评论 {comments.length > 0 ? `(${comments.length})` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 overflow-auto">
|
||||
<header className="flex items-center gap-4 mb-5">
|
||||
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
||||
{data.author.avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={data.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white/95 text-sm sm:text-base">{data.author.nickname}</div>
|
||||
<div className="text-xs text-white/50">发布于 {new Date(data.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-4 sm:space-y-5">
|
||||
{comments.map((c) => (
|
||||
<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">
|
||||
{c.user.avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
||||
<span className="text-xs text-white/50">{new Date(c.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">{c.text}</p>
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
|
||||
<ThumbsUp size={14} />
|
||||
<span>{c.digg_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
|
||||
function ms(v?: number | null) {
|
||||
if (!v) return "";
|
||||
const s = Math.round(v / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
||||
const id = (await params).awemeId;
|
||||
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
})
|
||||
]);
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const data = isVideo
|
||||
? {
|
||||
type: "video" as const,
|
||||
aweme_id: video!.aweme_id,
|
||||
desc: video!.desc,
|
||||
created_at: video!.created_at,
|
||||
duration_ms: video!.duration_ms,
|
||||
video_url: video!.video_url,
|
||||
width: video!.width ?? null,
|
||||
height: video!.height ?? null,
|
||||
author: { nickname: video!.author.nickname, avatar_url: video!.author.avatar_url },
|
||||
comments: video!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
}
|
||||
: {
|
||||
type: "image" as const,
|
||||
aweme_id: post!.aweme_id,
|
||||
desc: post!.desc,
|
||||
created_at: post!.created_at,
|
||||
images: post!.images.map((i) => ({ id: i.id, url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
|
||||
music_url: post!.music_url,
|
||||
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
|
||||
comments: post!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full overflow-hidden">
|
||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||
<div className="fixed left-3 top-3 z-30">
|
||||
<BackButton
|
||||
hrefFallback="/"
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
||||
/>
|
||||
</div>
|
||||
<AwemeDetailClient data={data as any} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
type BackButtonProps = {
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
hrefFallback?: string; // default '/'
|
||||
children?: React.ReactNode; // custom icon/content; defaults to ArrowLeft
|
||||
};
|
||||
|
||||
/**
|
||||
* BackButton
|
||||
* - Primary: behaves like browser back (router.back()), preserving previous page state (e.g., scroll, filters)
|
||||
* - Fallback: if no history entry exists (e.g., opened directly), navigates to '/'
|
||||
* - 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) {
|
||||
const router = useRouter();
|
||||
|
||||
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => {
|
||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
|
||||
// Prefer SPA back when we have some history to go back to
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}
|
||||
// else: allow default <Link href> to navigate to fallback
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={hrefFallback}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children ?? <ArrowLeft size={18} />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import HoverVideo from './HoverVideo';
|
||||
import { ThumbsUp } from 'lucide-react';
|
||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
|
||||
type Props = {
|
||||
initialItems: FeedItem[];
|
||||
initialCursor: string | null;
|
||||
};
|
||||
|
||||
export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
// 哨兵与容器
|
||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ended, setEnded] = useState(!initialCursor);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
||||
const getColumnCount = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 1;
|
||||
const w = window.innerWidth;
|
||||
if (w >= 1280) return 4; // xl
|
||||
if (w >= 1024) return 3; // lg
|
||||
if (w >= 640) return 2; // sm
|
||||
return 1;
|
||||
}, []);
|
||||
const [columnCount, setColumnCount] = useState<number>(getColumnCount());
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setColumnCount(getColumnCount());
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [getColumnCount]);
|
||||
|
||||
// 估算卡片高度(用于分配到“最短列”)
|
||||
const estimateItemHeight = useCallback((item: FeedItem, colWidth: number) => {
|
||||
// 媒体区域高度
|
||||
let mediaH = 200; // fallback
|
||||
if (item.width && item.height) {
|
||||
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth);
|
||||
} else if (item.type === 'video') {
|
||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||
}
|
||||
// 文本 + 作者栏的高度粗估
|
||||
const textH = 48; // 标题+标签区域
|
||||
const authorH = 48; // 作者行
|
||||
const gap = 16; // 卡片内边距/间隙
|
||||
return mediaH + textH + authorH + gap;
|
||||
}, []);
|
||||
|
||||
// 维护列数据与列高
|
||||
const [columns, setColumns] = useState<FeedItem[][]>(() => {
|
||||
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||
return cols;
|
||||
});
|
||||
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0));
|
||||
|
||||
// 初始化与当列数变化时重排
|
||||
useEffect(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
// 用 initialItems 重排
|
||||
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
||||
for (const item of initialItems) {
|
||||
// 找最短列
|
||||
let minIdx = 0;
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
if (newHeights[i] < newHeights[minIdx]) minIdx = i;
|
||||
}
|
||||
newCols[minIdx].push(item);
|
||||
newHeights[minIdx] += estimateItemHeight(item, colWidth || 300);
|
||||
}
|
||||
setColumns(newCols);
|
||||
setColHeights(newHeights);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnCount]);
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (loading || ended) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor) params.set('before', cursor);
|
||||
params.set('limit', '24');
|
||||
const res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: FeedResponse = await res.json();
|
||||
// 将新数据按最短列分配
|
||||
setColumns((prevCols) => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
const cols = prevCols.map((c) => [...c]);
|
||||
const heights = [...colHeights];
|
||||
for (const item of data.items) {
|
||||
// 找当前最短列
|
||||
let minIdx = 0;
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
if (heights[i] < heights[minIdx]) minIdx = i;
|
||||
}
|
||||
cols[minIdx].push(item);
|
||||
heights[minIdx] += estimateItemHeight(item, colWidth || 300);
|
||||
}
|
||||
setColHeights(heights);
|
||||
return cols;
|
||||
});
|
||||
setCursor(data.nextCursor);
|
||||
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
||||
} catch (e) {
|
||||
console.error('fetch more feed failed', e);
|
||||
// 失败也不要死循环
|
||||
setEnded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
fetchMore();
|
||||
}
|
||||
}, { rootMargin: '800px 0px 800px 0px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [fetchMore]);
|
||||
|
||||
const renderCard = useCallback((item: FeedItem) => (
|
||||
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} className="mb-4 block group">
|
||||
<article className="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 group-hover:-translate-y-1">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
||||
>
|
||||
{item.type === 'video' ? (
|
||||
<HoverVideo
|
||||
videoUrl={(item as any).video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
loading="lazy"
|
||||
src={item.cover_url || '/placeholder.svg'}
|
||||
alt={item.desc?.slice(0, 20) || 'image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === 'video' ? '视频' : '图文'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200">
|
||||
{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>
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300">{item.likes} </span><ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} />
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||
<div ref={containerRef} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}>
|
||||
{columns.map((col, idx) => (
|
||||
<div key={idx} className="flex flex-col">
|
||||
{col.map((item) => renderCard(item))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500">
|
||||
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type HoverVideoProps = {
|
||||
videoUrl: string;
|
||||
coverUrl?: string | null;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。
|
||||
*/
|
||||
export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const onEnter = useCallback(() => {
|
||||
setActive(true);
|
||||
// 播放在下一帧触发,避免 ref 尚未赋值
|
||||
requestAnimationFrame(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.play().catch(() => {});
|
||||
});
|
||||
}, [videoUrl]);
|
||||
|
||||
const onLeave = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
try {
|
||||
v.pause();
|
||||
v.load();
|
||||
} catch {}
|
||||
}
|
||||
setActive(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
{/* 封面始终渲染在底层 */}
|
||||
<img
|
||||
src={coverUrl || '/placeholder.svg'}
|
||||
alt="cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
loading='lazy'
|
||||
/>
|
||||
|
||||
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
|
||||
{active ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
autoPlay
|
||||
src={videoUrl}
|
||||
preload="none"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,170 @@
|
||||
// src/scrapeDouyin.ts
|
||||
import { chromium, type Response } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { createCamelCompatibleProxy } from '@/app/fetcher/utils';
|
||||
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/fetcher/network';
|
||||
import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media';
|
||||
import { handleImagePost } from '@/app/fetcher/uploader';
|
||||
import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist';
|
||||
|
||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
||||
export async function scrapeDouyin(url: string) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
console.log("Launch chromium");
|
||||
|
||||
const context = await chromium.launchPersistentContext('chrome-profile/douyin', { headless: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.addInitScript(() => {
|
||||
// 建一个全局容器存捕获的数据
|
||||
(window as any).__pace_captured__ = [];
|
||||
|
||||
// 用 Proxy 包装一个数组,拦截 push
|
||||
const captured = (window as any).__pace_captured__;
|
||||
const proxyArr = new Proxy([] as any[], {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'push') {
|
||||
return (...items: any[]) => {
|
||||
try { captured.push(...items); } catch { }
|
||||
return Array.prototype.push.apply(target, items);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
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/window 上的同名队列都指向我们的 proxy
|
||||
// 有些站点用 self,有些用 window
|
||||
(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 },
|
||||
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 },
|
||||
], 20_000); // 整体 20s 兜底超时,不逐个等待
|
||||
|
||||
// 评论只做短时“有就用、没有不等”的监听
|
||||
const commentPromise = waitForResponseWithTimeout(
|
||||
context,
|
||||
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200,
|
||||
8_000
|
||||
).catch(() => null);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
|
||||
const firstType = await firstTypePromise; // { key, response } | null
|
||||
const commentRes = await commentPromise; // Response | null
|
||||
|
||||
if (!firstType) {
|
||||
console.warn('无法判定作品类型(未捕获详情或图文接口)');
|
||||
const md = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
||||
// return {aweme: { detail: {} } };
|
||||
});
|
||||
let aweme_mem = md.aweme.detail as DouyinImageAweme;
|
||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
||||
|
||||
//@ts-ignore
|
||||
aweme_mem.author = aweme_mem.authorInfo
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
||||
return { type: "image", ...saved };
|
||||
}
|
||||
|
||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||
if (firstType.key === 'post') {
|
||||
// 图文作品
|
||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
||||
if (!postJson?.aweme_list?.length) throw new Error('图文作品响应为空');
|
||||
|
||||
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) {
|
||||
console.warn(`图文作品响应中未找到对应作品,look for aweme_id=${target_aweme_id}, have ${postJson.aweme_list.map(pt => pt.aweme_id).join(', ')}`);
|
||||
// Try read from memory
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000000));
|
||||
const md = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
||||
// return {aweme: { detail: {} } };
|
||||
});
|
||||
aweme = md.aweme.detail as DouyinImageAweme;
|
||||
}
|
||||
// console.log(aweme);
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000000));
|
||||
console.log(aweme);
|
||||
|
||||
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
||||
return { type: "image", ...saved };
|
||||
} else if (firstType.key === 'detail') {
|
||||
// 视频作品
|
||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
// 找到比特率最高的 url
|
||||
const bestPlayAddr = pickBestPlayAddr(
|
||||
detail?.aweme_detail?.video.bit_rate
|
||||
);
|
||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||
|
||||
console.log('Best video URL:', bestVUrl);
|
||||
|
||||
// 下载视频并上传至 MinIO,获取外链
|
||||
let uploadedUrl: string | undefined;
|
||||
let coverUrl: string | undefined;
|
||||
if (bestVUrl && detail?.aweme_detail) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
||||
const awemeId = detail.aweme_detail.aweme_id;
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
console.log('Uploaded to MinIO:', uploadedUrl);
|
||||
|
||||
// 提取首帧作为封面并上传
|
||||
try {
|
||||
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('Cover uploaded to MinIO:', coverUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Extract first frame failed, skip cover:', (e as Error)?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl);
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new Error('无法判定作品类型(未命中详情或图文接口)');
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
||||
if (!variants?.length) return null;
|
||||
|
||||
const best = variants.reduce((best, cur) => {
|
||||
const b1 = best?.bit_rate ?? -1;
|
||||
const b2 = cur?.bit_rate ?? -1;
|
||||
return b2 > b1 ? cur : best;
|
||||
});
|
||||
|
||||
return best?.play_addr ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||
*/
|
||||
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const tmpDir = os.tmpdir();
|
||||
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 {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-ss', '0',
|
||||
'-i', inPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '2',
|
||||
'-f', 'image2',
|
||||
'-y',
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
const img = await fs.readFile(outPath);
|
||||
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' };
|
||||
} 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;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
try { await fs.unlink(inPath); } catch { }
|
||||
try { await fs.unlink(outPath); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { BrowserContext, Response } from 'playwright';
|
||||
|
||||
export async function safeJson<T>(res: Response): Promise<T | null> {
|
||||
const ctype = res.headers()['content-type'] || '';
|
||||
if (ctype.includes('application/json')) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
const t = await res.text();
|
||||
try {
|
||||
return JSON.parse(t) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Playwright 的 APIRequestContext 下载二进制内容
|
||||
* - 使用指定 headers 模拟浏览器请求
|
||||
* - referrer 使用链接本身
|
||||
*/
|
||||
export async function downloadBinary(
|
||||
context: BrowserContext,
|
||||
url: string
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||
console.log('Download bin:', url);
|
||||
|
||||
const headers = {
|
||||
referer: url,
|
||||
} as Record<string, string>;
|
||||
|
||||
const res = await context.request.get(url, {
|
||||
headers,
|
||||
maxRedirects: 3,
|
||||
timeout: 240_000,
|
||||
failOnStatusCode: true,
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
|
||||
}
|
||||
|
||||
const buffer = await res.body();
|
||||
const contentType = res.headers()['content-type'] || 'application/octet-stream';
|
||||
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin';
|
||||
return { buffer, contentType, ext };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在多个候选匹配中“先到先得”地返回首个命中的 Response。
|
||||
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
||||
*/
|
||||
export function waitForFirstResponse(
|
||||
context: BrowserContext,
|
||||
candidates: { key: string; test: (r: Response) => boolean }[],
|
||||
timeoutMs = 20_000
|
||||
): Promise<{ key: string; response: Response } | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
if (resolved) return;
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (c.test(res)) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({ key: c.key, response: res });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待符合条件的单个 Response,带短超时;用于评论等“可有可无”的数据。
|
||||
*/
|
||||
export function waitForResponseWithTimeout(
|
||||
context: BrowserContext,
|
||||
predicate: (r: Response) => boolean,
|
||||
timeoutMs = 5_000
|
||||
): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
try {
|
||||
if (predicate(res)) {
|
||||
cleanup();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadAvatarFromUrl } from './uploader';
|
||||
import { firstUrl } from './utils';
|
||||
|
||||
export async function saveToDB(
|
||||
context: BrowserContext,
|
||||
detailResp: DouyinVideoDetailResponse,
|
||||
commentResp: DouyinCommentResponse,
|
||||
videoUrl?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
coverUrl?: string
|
||||
) {
|
||||
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
||||
const d = detailResp.aweme_detail;
|
||||
|
||||
// 1) Upsert Author
|
||||
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: d.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: d.author.sec_uid,
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? 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,
|
||||
},
|
||||
update: {
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? 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,
|
||||
},
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
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),
|
||||
videoId: video.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),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length };
|
||||
}
|
||||
|
||||
export async function saveImagePostToDB(
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme,
|
||||
commentResp: DouyinCommentResponse,
|
||||
uploads: { images: { url: string; width?: number; height?: number }[]; musicUrl?: string }
|
||||
) {
|
||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
||||
|
||||
// Upsert Author(与视频一致)
|
||||
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: aweme.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: aweme.author.sec_uid,
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? 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,
|
||||
},
|
||||
update: {
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImageFiles(按顺序)
|
||||
for (let i = 0; i < uploads.images.length; i++) {
|
||||
const { url, width, height } = uploads.images[i];
|
||||
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,
|
||||
},
|
||||
update: {
|
||||
url,
|
||||
width: typeof width === 'number' ? width : null,
|
||||
height: typeof height === 'number' ? height : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 评论入库:关联到 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,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scrapeDouyin } from '.';
|
||||
|
||||
async function handleDouyinScrape(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoUrl = searchParams.get('url');
|
||||
if (!videoUrl) {
|
||||
return NextResponse.json({ error: '缺少视频URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 调用爬虫函数
|
||||
const result = await scrapeDouyin(videoUrl);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export const GET = handleDouyinScrape
|
||||
Vendored
+138
@@ -0,0 +1,138 @@
|
||||
/** 抖音评论响应(精简版) */
|
||||
interface DouyinCommentResponse {
|
||||
status_code: number;
|
||||
comments: DouyinComment[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 单条评论(精简版) */
|
||||
interface DouyinComment {
|
||||
cid: string;
|
||||
text: string; // 评论内容
|
||||
digg_count: number; // 点赞数
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
user: DouyinUser; // 评论用户
|
||||
}
|
||||
|
||||
/** 用户信息(精简版) */
|
||||
interface DouyinUser {
|
||||
nickname: string; // 用户昵称
|
||||
avatar_thumb: {
|
||||
url_list: string[]; // 头像链接(可取第一个)
|
||||
};
|
||||
}
|
||||
|
||||
/** 抖音视频详情响应(精简版) */
|
||||
interface DouyinVideoDetailResponse {
|
||||
status_code: number;
|
||||
aweme_detail: DouyinVideoDetail;
|
||||
}
|
||||
/** 作者信息(精简版) */
|
||||
interface DouyinAuthor {
|
||||
uid: string; // 用户ID
|
||||
sec_uid: string; // 安全UID
|
||||
nickname: string; // 用户昵称
|
||||
signature: string; // 个性签名
|
||||
avatar_thumb: {
|
||||
url_list: string[]; // 头像URL(可取第一个)
|
||||
};
|
||||
follower_count: number; // 粉丝数
|
||||
total_favorited: number; // 获赞总数
|
||||
unique_id: string; // 抖音号
|
||||
short_id: string; // 短ID
|
||||
}
|
||||
/** 视频详情 */
|
||||
interface DouyinVideoDetail {
|
||||
aweme_id: string; // 视频ID
|
||||
desc: string; // 视频描述
|
||||
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
||||
duration: number; // 视频时长(毫秒)
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
share_url: string; // 视频分享链接
|
||||
|
||||
statistics: {
|
||||
digg_count: number; // 点赞数
|
||||
comment_count: number; // 评论数
|
||||
share_count: number; // 分享数
|
||||
collect_count: number; // 收藏数
|
||||
};
|
||||
|
||||
author: DouyinAuthor; // 作者信息
|
||||
video: VideoPlayBasic;
|
||||
tags: VideoTagBasic[];
|
||||
}
|
||||
|
||||
/** 视频播放信息 */
|
||||
interface VideoPlayBasic {
|
||||
/** 多种清晰度/编码的可播放变体(选其一即可) */
|
||||
bit_rate: PlayVariant[];
|
||||
/** 大缩略图(如果需要封面) */
|
||||
big_thumb_url?: string;
|
||||
}
|
||||
|
||||
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
||||
interface PlayVariant {
|
||||
format: string; // mp4 等
|
||||
FPS: number;
|
||||
bit_rate: number; // bit_rate.bit_rate
|
||||
|
||||
/** 直连播放地址(最关键) */
|
||||
play_addr: {
|
||||
uri: string;
|
||||
url_list: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
data_size: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 视频标签(精简) */
|
||||
interface VideoTagBasic {
|
||||
tag_id: number;
|
||||
tag_name: string;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
/** 图文作品列表响应(POST_PATH) */
|
||||
interface DouyinPostListResponse {
|
||||
status_code: number;
|
||||
aweme_list: DouyinImageAweme[];
|
||||
}
|
||||
|
||||
/** 图文作品(精简必要字段) */
|
||||
interface DouyinImageAweme {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
create_time: number; // 秒
|
||||
share_url: string;
|
||||
statistics: {
|
||||
digg_count: number;
|
||||
comment_count: number;
|
||||
share_count: number;
|
||||
collect_count: number;
|
||||
};
|
||||
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
||||
images: DouyinImageInfo[]; // 图片列表
|
||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||
video_tag?: VideoTagBasic[]; // 标签
|
||||
}
|
||||
|
||||
/** 图文作品的单张图片信息(精简) */
|
||||
interface DouyinImageInfo {
|
||||
url_list: string[]; // 多种格式(webp/jpeg)
|
||||
download_url_list?: string[]; // 可能带水印
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 音乐基本信息(精简) */
|
||||
interface DouyinMusicBasic {
|
||||
id?: number | string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
album?: string;
|
||||
play_url: {
|
||||
uri?: string;
|
||||
url_list: string[]; // 真实可下载地址
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { downloadBinary } from './network';
|
||||
import { pickFirstUrl } from './utils';
|
||||
|
||||
/**
|
||||
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||
*/
|
||||
export async function uploadAvatarFromUrl(
|
||||
context: BrowserContext,
|
||||
srcUrl?: string | null,
|
||||
nameHint?: string
|
||||
): Promise<string | undefined> {
|
||||
if (!srcUrl) return undefined;
|
||||
try {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||
const safeExt = ext || 'jpg';
|
||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
||||
const fileName = generateUniqueFileName(baseName, 'douyin/avatars');
|
||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
return uploaded;
|
||||
} catch (e) {
|
||||
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
||||
return srcUrl || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
||||
export async function handleImagePost(
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme
|
||||
): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> {
|
||||
const awemeId = aweme.aweme_id;
|
||||
const uploadedImages: { url: string; width?: number; height?: number }[] = [];
|
||||
|
||||
// 下载图片(顺序保持)
|
||||
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||
const img = aweme.images[i];
|
||||
const url = pickFirstUrl(img?.url_list);
|
||||
if (!url) continue;
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
||||
const safeExt = ext || 'jpg';
|
||||
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
||||
}
|
||||
|
||||
// 下载音乐(可选)
|
||||
let musicUrl: string | undefined;
|
||||
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
||||
if (audioSrc) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc);
|
||||
const safeExt = ext || 'mp3';
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios');
|
||||
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
}
|
||||
|
||||
return { images: uploadedImages, musicUrl };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export function toCamelCaseKey(key: string): string {
|
||||
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function toSnakeCaseKey(key: string): string {
|
||||
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个代理对象,使得读取属性时同时兼容 snake_case 与 camelCase 键名。
|
||||
* 访问顺序:原名 -> camelCase -> snake_case
|
||||
*/
|
||||
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
||||
const seen = new WeakMap<object, any>();
|
||||
|
||||
const wrap = (value: any): any => {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
const proxied = new Proxy(value, handler);
|
||||
seen.set(value, proxied);
|
||||
return proxied;
|
||||
};
|
||||
|
||||
const handler: ProxyHandler<any> = {
|
||||
get(target, prop, receiver) {
|
||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||
if (typeof prop !== 'string') {
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
}
|
||||
|
||||
const primary = prop;
|
||||
|
||||
if (primary in target) return wrap(Reflect.get(target, primary, receiver));
|
||||
|
||||
const camel = toCamelCaseKey(primary);
|
||||
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
||||
|
||||
const snake = toSnakeCaseKey(primary);
|
||||
if (snake in target) return wrap(Reflect.get(target, snake, receiver));
|
||||
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop !== 'string') return prop in target;
|
||||
const primary = prop === 'auther' ? 'autherInfo' : prop;
|
||||
return (
|
||||
primary in target ||
|
||||
toCamelCaseKey(primary) in target ||
|
||||
toSnakeCaseKey(primary) in target
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return wrap(root);
|
||||
}
|
||||
|
||||
/** 选择首个可用 URL */
|
||||
export function pickFirstUrl(list?: string[]) {
|
||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||
}
|
||||
|
||||
// 别名,兼容旧命名
|
||||
export const firstUrl = pickFirstUrl;
|
||||
@@ -0,0 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #161823; /* theme background */
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #161823;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* 滚动条隐藏 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
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 = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import FeedMasonry from "./components/FeedMasonry";
|
||||
import type { FeedItem } from "./types/feed";
|
||||
|
||||
export default async function Home() {
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const feed: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: "video" as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: v.video_url,
|
||||
cover_url: v.cover_url ?? null,
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
||||
likes: Number(v.digg_count)
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: p.images?.[0]?.url ?? null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
||||
likes: Number(p.digg_count)
|
||||
})),
|
||||
]
|
||||
//.sort(() => Math.random() - 0.5)
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full px-4 py-8 md:py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
|
||||
作品集
|
||||
</h1>
|
||||
|
||||
{(() => {
|
||||
const initial = feed.slice(0, 24);
|
||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
||||
})()}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
|
||||
|
||||
type TaskStatus = "pending" | "running" | "success" | "error";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
url: string;
|
||||
status: TaskStatus;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
error?: string;
|
||||
result?: any;
|
||||
};
|
||||
|
||||
const extractDouyinLinks = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
const regex = /https?:\/\/v\.douyin\.com\/\S+/g; // 粗略匹配直到空白
|
||||
const matches = text.match(regex) ?? [];
|
||||
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
||||
const cleaned = matches
|
||||
.map((m) => m.replace(trailing, ""))
|
||||
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
|
||||
// 去重
|
||||
return Array.from(new Set(cleaned));
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const [input, setInput] = useState("");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const controllers = useRef<Map<string, AbortController>>(new Map());
|
||||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||||
|
||||
const inProgressUrls = useMemo(
|
||||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const addTasks = useCallback((urls: string[]) => {
|
||||
if (!urls.length) return;
|
||||
const now = Date.now();
|
||||
setTasks((prev) => {
|
||||
const existing = new Set(prev.map((t) => t.id));
|
||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
||||
const next: Task[] = [...prev];
|
||||
for (const url of notDuplicated) {
|
||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
next.push({ id, url, status: "pending" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [inProgressUrls]);
|
||||
|
||||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const urls = extractDouyinLinks(input);
|
||||
if (!urls.length) {
|
||||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
||||
return;
|
||||
}
|
||||
addTasks(urls);
|
||||
setInput("");
|
||||
}, [input, addTasks]);
|
||||
|
||||
const startTask = useCallback(async (task: Task) => {
|
||||
// 若已存在控制器,避免重复启动
|
||||
if (controllers.current.has(task.id)) return;
|
||||
const ctrl = new AbortController();
|
||||
controllers.current.set(task.id, ctrl);
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now() } : t));
|
||||
try {
|
||||
const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `请求失败: ${res.status}`);
|
||||
}
|
||||
const data = await res.json().catch(() => undefined);
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
||||
} catch (err: any) {
|
||||
const msg = 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 {
|
||||
controllers.current.delete(task.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
||||
useEffect(() => {
|
||||
const pending = tasks.filter(t => t.status === "pending");
|
||||
pending.forEach((t) => startTask(t));
|
||||
}, [tasks, startTask]);
|
||||
|
||||
const cancelTask = useCallback((id: string) => {
|
||||
const ctrl = controllers.current.get(id);
|
||||
if (ctrl) ctrl.abort();
|
||||
controllers.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const clearFinished = useCallback(() => {
|
||||
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
||||
}, []);
|
||||
|
||||
const toggleOpen = useCallback((id: string) => {
|
||||
setOpenDetails(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
})
|
||||
}, []);
|
||||
|
||||
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
|
||||
|
||||
const formatDuration = (t?: number) => {
|
||||
if (!t) return "";
|
||||
const secs = Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}m ${s}s`;
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
||||
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||||
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>;
|
||||
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 (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-neutral-950 text-neutral-100">
|
||||
{/* 背景渐变(明确置于内容层后面) */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 opacity-40 [mask-image:radial-gradient(60%_60%_at_50%_0%,#000_30%,transparent_70%)]">
|
||||
<div className="absolute -top-1/2 left-1/2 h-[120vh] w-[120vh] -translate-x-1/2 rounded-full bg-[conic-gradient(at_50%_50%,#312e81_0deg,#0ea5e9_120deg,#10b981_240deg,#312e81_360deg)] blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-5 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||
抖音采集任务
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
|
||||
</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">
|
||||
<div className="flex items-start gap-3">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<textarea
|
||||
className="w-full min-h-28 resize-y rounded-md border border-neutral-800/60 bg-neutral-900/60 px-3 py-2 text-sm placeholder:text-neutral-500 focus:border-indigo-700/50 focus:outline-none focus:ring-2 focus:ring-indigo-700/30"
|
||||
placeholder="例如:2.51 05/08 f@b.AT EUy:/ 无意之中... https://v.douyin.com/dP22IOH8uAI/ 复制此链接..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>已提取 {extractedCount} 个短链</span>
|
||||
<span className="hidden sm:inline">快捷键:Ctrl / Cmd + Enter 提交</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex 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">
|
||||
<Plus className="h-4 w-4" /> 添加任务
|
||||
</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">
|
||||
<Trash2 className="h-4 w-4" /> 清除已完成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">进行中的任务</h2>
|
||||
<span className="text-xs text-neutral-400">共 {tasks.length} 个</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{tasks.map((t) => {
|
||||
const isOpen = openDetails.has(t.id);
|
||||
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">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={t.status} />
|
||||
{t.status === 'running' && (
|
||||
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span>
|
||||
)}
|
||||
{t.status === 'success' && (
|
||||
<span className="text-xs text-neutral-400">用时 {t.startedAt ? formatDuration(t.startedAt) : '--'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
||||
<span className="truncate" title={t.url}>{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" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 space-x-2">
|
||||
{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">
|
||||
<Square className="h-3.5 w-3.5"/> 取消
|
||||
</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">
|
||||
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{t.status === 'running' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||||
{t.status === 'error' && (
|
||||
<div className="mb-2 inline-flex items-center gap-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200">
|
||||
<AlertTriangle className="h-4 w-4"/> {t.error}
|
||||
</div>
|
||||
)}
|
||||
{typeof t.result !== 'undefined' && (
|
||||
<pre className="max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
||||
)}
|
||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<li className="rounded-xl border border-dashed border-white/10 bg-white/[0.03] p-8 text-center text-sm text-neutral-400">
|
||||
暂无任务,粘贴包含 Douyin 短链的文本后点击“添加任务”。
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 进度条动画 keyframes */}
|
||||
<style>{`@keyframes progress{0%{transform:translateX(-100%)}50%{transform:translateX(10%)}100%{transform:translateX(120%)}}`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type FeedItem =
|
||||
| ({
|
||||
type: "video";
|
||||
video_url: string;
|
||||
} | {
|
||||
type: "image";
|
||||
}) & {
|
||||
likes: number;
|
||||
author: { nickname: string; avatar_url: string | null };
|
||||
aweme_id: string;
|
||||
created_at: Date | string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItem[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user