This commit is contained in:
feie9456 2025-10-20 13:06:06 +08:00
commit 9b45c4e3e8
48 changed files with 5843 additions and 0 deletions

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/app/generated/prisma
chrome-profile

18
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "tsc-check",
"type": "shell",
"command": "node",
"args": [
"-e",
"require('typescript').transpile('const x: number = 1;')"
],
"problemMatcher": [
"$tsc"
],
"group": "build"
}
]
}

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
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.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

64
app/api/feed/route.ts Normal file
View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

170
app/fetcher/index.ts Normal file
View File

@ -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();
}
}

57
app/fetcher/media.ts Normal file
View File

@ -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 { }
}
}

130
app/fetcher/network.ts Normal file
View File

@ -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);
}
});
}

263
app/fetcher/persist.ts Normal file
View File

@ -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 };
}

17
app/fetcher/route.ts Normal file
View File

@ -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

138
app/fetcher/types.d.ts vendored Normal file
View File

@ -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[]; // 真实可下载地址
};
}

59
app/fetcher/uploader.ts Normal file
View File

@ -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 };
}

63
app/fetcher/utils.ts Normal file
View File

@ -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;

31
app/globals.css Normal file
View File

@ -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; }

34
app/layout.tsx Normal file
View File

@ -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>
);
}

60
app/page.tsx Normal file
View File

@ -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>
);
}

257
app/tasks/page.tsx Normal file
View File

@ -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>
);
}

21
app/types/feed.ts Normal file
View File

@ -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;
}

37
biome.json Normal file
View File

@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

452
bun.lock Normal file
View File

@ -0,0 +1,452 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "douyin-archive",
"dependencies": {
"@prisma/client": "^6.16.3",
"lucide-react": "^0.546.0",
"minio": "^8.0.6",
"next": "15.5.6",
"playwright": "^1.56.1",
"react": "19.1.0",
"react-dom": "19.1.0",
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"prisma": "^6.16.3",
"tailwindcss": "^4",
"typescript": "^5",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@biomejs/biome": ["@biomejs/biome@2.2.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.0", "@biomejs/cli-darwin-x64": "2.2.0", "@biomejs/cli-linux-arm64": "2.2.0", "@biomejs/cli-linux-arm64-musl": "2.2.0", "@biomejs/cli-linux-x64": "2.2.0", "@biomejs/cli-linux-x64-musl": "2.2.0", "@biomejs/cli-win32-arm64": "2.2.0", "@biomejs/cli-win32-x64": "2.2.0" }, "bin": { "biome": "bin/biome" } }, "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww=="],
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "^1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@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/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="],
"@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-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="],
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
"@prisma/config": ["@prisma/config@6.17.1", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA=="],
"@prisma/debug": ["@prisma/debug@6.17.1", "", {}, "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ=="],
"@prisma/engines": ["@prisma/engines@6.17.1", "", { "dependencies": { "@prisma/debug": "6.17.1", "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", "@prisma/fetch-engine": "6.17.1", "@prisma/get-platform": "6.17.1" } }, "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw=="],
"@prisma/engines-version": ["@prisma/engines-version@6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", "", {}, "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.17.1", "", { "dependencies": { "@prisma/debug": "6.17.1", "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", "@prisma/get-platform": "6.17.1" } }, "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg=="],
"@prisma/get-platform": ["@prisma/get-platform@6.17.1", "", { "dependencies": { "@prisma/debug": "6.17.1" } }, "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
"@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"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=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="],
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
}
}

317
lib/minio-examples.ts Normal file
View File

@ -0,0 +1,317 @@
/**
* MinIO 使
* 使 minio.ts
*/
import {
uploadFile,
getFileUrl,
deleteFile,
listFiles,
generateUniqueFileName,
validateFileType,
validateFileSize,
downloadFile,
fileExists,
getFileInfo,
} from './minio';
// ========================================
// 1. 上传文件示例
// ========================================
/**
*
*/
async function uploadAvatar(file: File, userId: string) {
// 验证文件类型
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式');
}
// 验证文件大小5MB
const maxSize = 5 * 1024 * 1024;
if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制最大5MB');
}
// 生成唯一文件名,存储在 avatars 目录下
const path = generateUniqueFileName(file.name, `avatars/${userId}`);
// 上传文件
const url = await uploadFile(file, path, {
'Content-Type': file.type,
'User-Id': userId,
});
return { url, path };
}
/**
*
*/
async function uploadPostCover(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp'];
if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式');
}
const maxSize = 10 * 1024 * 1024; // 10MB
if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制最大10MB');
}
// 按日期组织文件
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const path = generateUniqueFileName(
file.name,
`posts/${year}/${month}/covers`
);
const url = await uploadFile(file, path);
return { url, path };
}
/**
*
*/
async function uploadPostImage(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式');
}
const maxSize = 5 * 1024 * 1024; // 5MB
if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制最大5MB');
}
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const path = generateUniqueFileName(
file.name,
`posts/${year}/${month}/images`
);
const url = await uploadFile(file, path);
return { url, path };
}
// ========================================
// 2. 获取文件URL示例
// ========================================
/**
* 访URL
*/
function getPublicFileUrl(path: string) {
return getFileUrl(path);
}
// ========================================
// 3. 删除文件示例
// ========================================
/**
*
*/
async function deleteAvatar(avatarPath: string) {
try {
await deleteFile(avatarPath);
console.log('头像删除成功');
} catch (error) {
console.error('删除头像失败:', error);
throw error;
}
}
/**
*
*/
async function deletePostImages(postId: string) {
try {
// 列出文章相关的所有图片
const files = await listFiles(`posts/`, true);
// 过滤出该文章的图片(根据实际情况调整逻辑)
const postFiles = files.filter(file =>
file.name?.includes(postId)
);
// 批量删除
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name);
if (paths.length > 0) {
const { deleteFiles } = await import('./minio');
await deleteFiles(paths);
console.log(`删除了 ${paths.length} 个文件`);
}
} catch (error) {
console.error('删除文章图片失败:', error);
throw error;
}
}
// ========================================
// 4. 列出文件示例
// ========================================
/**
*
*/
async function getUserAvatars(userId: string) {
try {
const files = await listFiles(`avatars/${userId}/`, false);
return files.map(file => ({
name: file.name,
size: file.size,
lastModified: file.lastModified,
url: file.name ? getFileUrl(file.name) : null,
}));
} catch (error) {
console.error('获取用户头像列表失败:', error);
throw error;
}
}
/**
*
*/
async function getPostCoversByMonth(year: number, month: number) {
try {
const monthStr = String(month).padStart(2, '0');
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
return files.map(file => ({
name: file.name,
size: file.size,
url: file.name ? getFileUrl(file.name) : null,
}));
} catch (error) {
console.error('获取封面列表失败:', error);
throw error;
}
}
// ========================================
// 5. 检查文件是否存在
// ========================================
/**
*
*/
async function checkAvatarExists(avatarPath: string): Promise<boolean> {
return await fileExists(avatarPath);
}
// ========================================
// 6. 获取文件信息
// ========================================
/**
*
*/
async function getFileDetails(path: string) {
try {
const info = await getFileInfo(path);
return {
size: info.size,
lastModified: info.lastModified,
etag: info.etag,
contentType: info.metaData?.['content-type'],
};
} catch (error) {
console.error('获取文件信息失败:', error);
throw error;
}
}
// ========================================
// 7. 下载文件示例
// ========================================
/**
*
*/
async function downloadFileToBuffer(path: string): Promise<Buffer> {
try {
return await downloadFile(path);
} catch (error) {
console.error('下载文件失败:', error);
throw error;
}
}
// ========================================
// 8. 在 API Route 中使用示例
// ========================================
/**
* Next.js API Route
*
* 使
*
* // app/api/upload/route.ts
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
*
* export async function POST(request: Request) {
* const formData = await request.formData();
* const file = formData.get('file') as File;
*
* if (!file) {
* return Response.json({ error: '没有文件' }, { status: 400 });
* }
*
* const path = generateUniqueFileName(file.name, 'uploads');
* const url = await uploadFile(file, path);
*
* return Response.json({ url, path });
* }
*/
/**
* Next.js API Route
*
* // app/api/delete/route.ts
* import { deleteFile } from '@/lib/minio';
*
* export async function DELETE(request: Request) {
* const { path } = await request.json();
*
* if (!path) {
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
* }
*
* await deleteFile(path);
*
* return Response.json({ success: true });
* }
*/
// ========================================
// 导出示例函数
// ========================================
export {
uploadAvatar,
uploadPostCover,
uploadPostImage,
getPublicFileUrl,
deleteAvatar,
deletePostImages,
getUserAvatars,
getPostCoversByMonth,
checkAvatarExists,
getFileDetails,
downloadFileToBuffer,
};

340
lib/minio.ts Normal file
View File

@ -0,0 +1,340 @@
import * as Minio from 'minio';
// MinIO 客户端配置
const useSSL = process.env.MINIO_USE_SSL === 'true';
const port = Number(process.env.MINIO_PORT) || 9000;
// 当使用标准HTTPS端口443或HTTP端口80MinIO客户端不需要指定端口
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
...(shouldOmitPort ? {} : { port }),
useSSL,
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || '',
pathStyle: true, // 使用路径风格,对反向代理更友好
});
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page';
/**
* MinIO Bucket bucket
*/
export async function initBucket(): Promise<void> {
try {
const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1');
console.log(`Bucket ${BUCKET_NAME} created successfully`);
}
// 设置公共读取策略(可选)
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
},
],
};
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
} catch (error) {
console.error('Error initializing bucket:', error);
throw error;
}
}
/**
* MinIO
* @param file - File Buffer
* @param path - : 'avatars/user123.jpg' 'posts/2024/image.png'
* @param metadata -
* @returns 访URL
*/
export async function uploadFile(
file: File | Buffer,
path: string,
metadata?: Record<string, string>
): Promise<string> {
try {
await initBucket();
let buffer: Buffer;
let contentType: string;
if (file instanceof File) {
buffer = Buffer.from(await file.arrayBuffer());
contentType = file.type || 'application/octet-stream';
} else {
buffer = file;
contentType = metadata?.['Content-Type'] || 'application/octet-stream';
}
const metaData = {
'Content-Type': contentType,
...metadata,
};
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData);
return getFileUrl(path);
} catch (error) {
console.error('Error uploading file:', error);
throw error;
}
}
/**
* 访URL
* @param path -
* @returns 访URL
*/
export function getFileUrl(path: string): string {
const endpoint = process.env.MINIO_REAL_DOMAIN;
return `${endpoint}/${BUCKET_NAME}/${path}`;
}
/**
* 访URL
* @param path -
* @param expirySeconds - 7
* @returns 访URL
*/
export async function getPresignedUrl(
path: string,
expirySeconds: number = 7 * 24 * 60 * 60
): Promise<string> {
try {
return await minioClient.presignedGetObject(BUCKET_NAME, path, expirySeconds);
} catch (error) {
console.error('Error generating presigned URL:', error);
throw error;
}
}
/**
*
* @param path -
* @returns Buffer
*/
export async function downloadFile(path: string): Promise<Buffer> {
try {
const chunks: Buffer[] = [];
const stream = await minioClient.getObject(BUCKET_NAME, path);
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
} catch (error) {
console.error('Error downloading file:', error);
throw error;
}
}
/**
*
* @param path -
* @returns
*/
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> {
try {
return await minioClient.getObject(BUCKET_NAME, path);
} catch (error) {
console.error('Error getting file stream:', error);
throw error;
}
}
/**
*
* @param path -
*/
export async function deleteFile(path: string): Promise<void> {
try {
await minioClient.removeObject(BUCKET_NAME, path);
} catch (error) {
console.error('Error deleting file:', error);
throw error;
}
}
/**
*
* @param paths -
*/
export async function deleteFiles(paths: string[]): Promise<void> {
try {
await minioClient.removeObjects(BUCKET_NAME, paths);
} catch (error) {
console.error('Error deleting files:', error);
throw error;
}
}
/**
*
* @param path -
* @returns
*/
export async function fileExists(path: string): Promise<boolean> {
try {
await minioClient.statObject(BUCKET_NAME, path);
return true;
} catch (error) {
return false;
}
}
/**
*
* @param path -
* @returns
*/
export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
try {
return await minioClient.statObject(BUCKET_NAME, path);
} catch (error) {
console.error('Error getting file info:', error);
throw error;
}
}
/**
*
* @param prefix - : 'avatars/' 'posts/2024/'
* @param recursive -
* @returns
*/
export async function listFiles(
prefix: string = '',
recursive: boolean = false
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
try {
const files: (Minio.BucketItem & { endpoint: string })[] = [];
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
return new Promise((resolve, reject) => {
stream.on('data', (obj) => {
if (obj.name) {
files.push({ endpoint: `${process.env.MINIO_REAL_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string });
}
});
stream.on('end', () => resolve(files));
stream.on('error', reject);
});
} catch (error) {
console.error('Error listing files:', error);
throw error;
}
}
/**
*
* @param sourcePath -
* @param destPath -
*/
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
try {
const conds = new Minio.CopyConditions();
await minioClient.copyObject(
BUCKET_NAME,
destPath,
`/${BUCKET_NAME}/${sourcePath}`,
conds
);
} catch (error) {
console.error('Error copying file:', error);
throw error;
}
}
/**
*
* @param originalName -
* @param prefix - : 'avatars/' 'posts/2024/'
* @returns
*/
export function generateUniqueFileName(originalName: string, prefix: string = ''): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const ext = originalName.split('.').pop();
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_');
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName;
}
/**
*
* @param filename -
* @returns
*/
export function getFileExtension(filename: string): string {
return filename.split('.').pop() || '';
}
/**
*
* @param filename -
* @param allowedTypes - : ['jpg', 'png', 'gif']
* @returns
*/
export function validateFileType(filename: string, allowedTypes: string[]): boolean {
const ext = getFileExtension(filename).toLowerCase();
return allowedTypes.map(t => t.toLowerCase()).includes(ext);
}
/**
*
* @param size -
* @param maxSize -
* @returns
*/
export function validateFileSize(size: number, maxSize: number): boolean {
return size <= maxSize;
}
/**
*
* @param bytes -
* @returns
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export default {
initBucket,
uploadFile,
getFileUrl,
getPresignedUrl,
downloadFile,
getFileStream,
deleteFile,
deleteFiles,
fileExists,
getFileInfo,
listFiles,
copyFile,
generateUniqueFileName,
getFileExtension,
validateFileType,
validateFileSize,
formatFileSize,
};

9
lib/prisma.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

1787
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "douyin-archive",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "biome check",
"format": "biome format --write"
},
"dependencies": {
"@prisma/client": "^6.16.3",
"lucide-react": "^0.546.0",
"minio": "^8.0.6",
"next": "15.5.6",
"playwright": "^1.56.1",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"prisma": "^6.16.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

31
pm2.config.cjs Normal file
View File

@ -0,0 +1,31 @@
const path = require('path');
const dotenv = require('dotenv');
const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? '1', 10) || 1;
const { parsed: envFromFile = {} } = dotenv.config({ path: path.join(__dirname, '.env') });
module.exports = {
apps: [
{
name: "DouyinArchive",
script: 'index.ts',
cwd: __dirname,
autorestart: true,
restart_delay: 4000,
kill_timeout: 5000,
instances,
exec_mode: instances > 1 ? 'cluster' : 'fork',
watch: process.env.NODE_ENV !== 'production',
ignore_watch: ['generated', 'node_modules', '.git'],
env: {
...envFromFile,
NODE_ENV: 'development'
},
env_production: {
...envFromFile,
NODE_ENV: 'production'
},
time: true
}
]
};

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -0,0 +1,84 @@
-- CreateTable
CREATE TABLE "Author" (
"sec_uid" TEXT NOT NULL,
"uid" TEXT,
"nickname" TEXT NOT NULL,
"signature" TEXT,
"avatar_url" TEXT,
"follower_count" BIGINT NOT NULL DEFAULT 0,
"total_favorited" BIGINT NOT NULL DEFAULT 0,
"unique_id" TEXT,
"short_id" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Author_pkey" PRIMARY KEY ("sec_uid")
);
-- CreateTable
CREATE TABLE "Video" (
"aweme_id" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"preview_title" TEXT,
"duration_ms" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL,
"share_url" TEXT NOT NULL,
"digg_count" BIGINT NOT NULL DEFAULT 0,
"comment_count" BIGINT NOT NULL DEFAULT 0,
"share_count" BIGINT NOT NULL DEFAULT 0,
"collect_count" BIGINT NOT NULL DEFAULT 0,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Video_pkey" PRIMARY KEY ("aweme_id")
);
-- CreateTable
CREATE TABLE "CommentUser" (
"id" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"avatar_url" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommentUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"cid" TEXT NOT NULL,
"text" TEXT NOT NULL,
"digg_count" BIGINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL,
"videoId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("cid")
);
-- CreateIndex
CREATE UNIQUE INDEX "Author_uid_key" ON "Author"("uid");
-- CreateIndex
CREATE INDEX "Video_authorId_idx" ON "Video"("authorId");
-- CreateIndex
CREATE INDEX "Video_created_at_idx" ON "Video"("created_at");
-- CreateIndex
CREATE UNIQUE INDEX "CommentUser_nickname_avatar_url_key" ON "CommentUser"("nickname", "avatar_url");
-- CreateIndex
CREATE INDEX "Comment_videoId_created_at_idx" ON "Comment"("videoId", "created_at");
-- AddForeignKey
ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
/*
Warnings:
- Added the required column `video_url` to the `Video` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Video" ADD COLUMN "tags" TEXT[],
ADD COLUMN "video_url" TEXT NOT NULL;

View File

@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "ImagePost" (
"aweme_id" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL,
"share_url" TEXT NOT NULL,
"digg_count" BIGINT NOT NULL DEFAULT 0,
"comment_count" BIGINT NOT NULL DEFAULT 0,
"share_count" BIGINT NOT NULL DEFAULT 0,
"collect_count" BIGINT NOT NULL DEFAULT 0,
"authorId" TEXT NOT NULL,
"tags" TEXT[],
"music_url" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ImagePost_pkey" PRIMARY KEY ("aweme_id")
);
-- CreateTable
CREATE TABLE "ImageFile" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"width" INTEGER,
"height" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ImageFile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImagePost_authorId_idx" ON "ImagePost"("authorId");
-- CreateIndex
CREATE INDEX "ImagePost_created_at_idx" ON "ImagePost"("created_at");
-- CreateIndex
CREATE INDEX "ImageFile_postId_order_idx" ON "ImageFile"("postId", "order");
-- CreateIndex
CREATE UNIQUE INDEX "ImageFile_postId_order_key" ON "ImageFile"("postId", "order");
-- AddForeignKey
ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey";
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "imagePostId" TEXT,
ALTER COLUMN "videoId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "Comment_imagePostId_created_at_idx" ON "Comment"("imagePostId", "created_at");
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Video" ADD COLUMN "height" INTEGER,
ADD COLUMN "width" INTEGER;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Video" ADD COLUMN "cover_url" TEXT;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

142
prisma/schema.prisma Normal file
View File

@ -0,0 +1,142 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Author {
// 抖音作者;以 sec_uid 稳定标识
sec_uid String @id
uid String? @unique
nickname String
signature String?
avatar_url String?
follower_count BigInt @default(0)
total_favorited BigInt @default(0)
unique_id String? // 抖音号
short_id String?
videos Video[]
imagePosts ImagePost[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Video {
aweme_id String @id
desc String
preview_title String?
duration_ms Int
created_at DateTime
share_url String
digg_count BigInt @default(0)
comment_count BigInt @default(0)
share_count BigInt @default(0)
collect_count BigInt @default(0)
// 视频分辨率(用于前端预布局)
width Int?
height Int?
// 视频封面(首帧提取后上传到 MinIO 的外链)
cover_url String?
authorId String
author Author @relation(fields: [authorId], references: [sec_uid])
comments Comment[]
tags String[] // 视频标签列表
video_url String // 视频文件 URL
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([created_at])
}
model CommentUser {
id String @id @default(cuid())
nickname String
avatar_url String?
// 以 (nickname, avatar_url) 近似去重;如果你从响应里拿到用户 uid可以改为以 uid 作为主键
@@unique([nickname, avatar_url])
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
cid String @id
text String
digg_count BigInt @default(0)
created_at DateTime
// 可关联视频或图文中的一种
videoId String?
video Video? @relation(fields: [videoId], references: [aweme_id])
imagePostId String?
imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id])
userId String
user CommentUser @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([videoId, created_at])
@@index([imagePostId, created_at])
}
// 图片作品(图文)
model ImagePost {
aweme_id String @id
desc String
created_at DateTime
share_url String
digg_count BigInt @default(0)
comment_count BigInt @default(0)
share_count BigInt @default(0)
collect_count BigInt @default(0)
authorId String
author Author @relation(fields: [authorId], references: [sec_uid])
tags String[]
music_url String? // 背景音乐(已上传到 MinIO 的外链)
images ImageFile[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([created_at])
}
// 图片作品中的单张图片(已上传到 MinIO 的外链)
model ImageFile {
id String @id @default(cuid())
postId String
post ImagePost @relation(fields: [postId], references: [aweme_id])
url String
order Int // 在作品中的顺序(从 0 开始)
width Int?
height Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([postId, order])
@@unique([postId, order])
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

36
test.ts Normal file
View File

@ -0,0 +1,36 @@
import { createWriteStream, writeFileSync } from "node:fs";
initBucket()
const response = await fetch("https://v3-web.douyinvod.com/95ee4b6a1f064d04653f506024e3d493/68f4daca/video/tos/cn/tos-cn-ve-15c000-ce/oojxjQ2XsXPPgPniGM4kz8M3BSEahiGNcA0AI/?a=6383\\u0026ch=26\\u0026cr=3\\u0026dr=0\\u0026lr=all\\u0026cd=0%7C0%7C0%7C3\\u0026cv=1\\u0026br=6662\\u0026bt=6662\\u0026cs=2\\u0026ds=10\\u0026ft=khyHAB1UiiuGzJrZ~~OC~49Zyo3nOz7HQNaLpMyC6LZjrKQ2B22E1J6kcKb2oPd.o~\\u0026mime_type=video_mp4\\u0026qs=15\\u0026rc=ODNoPGk2MzRnNDg7NDdnNUBpM3ZzbXA5cjNyNjMzbGkzNEAzMy41MzU1XzMxNTY2Ll9jYSNmYy1tMmQ0b2xhLS1kLWJzcw%3D%3D\\u0026btag=c0000e00028000\\u0026cquery=100w_100B_100x_100z_100o\\u0026dy_q=1760866258\\u0026feature_id=10cf95ef75b4f3e7eac623e4ea0ea691\\u0026l=20251019173058DF8022814E036CA4C097", {
"headers": {
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "video",
"sec-fetch-mode": "no-cors",
"sec-fetch-site": "same-origin"
},
"referrer": "https://v3-web.douyinvod.com/95ee4b6a1f064d04653f506024e3d493/68f4daca/video/tos/cn/tos-cn-ve-15c000-ce/oojxjQ2XsXPPgPniGM4kz8M3BSEahiGNcA0AI/?a=6383\\u0026ch=26\\u0026cr=3\\u0026dr=0\\u0026lr=all\\u0026cd=0%7C0%7C0%7C3\\u0026cv=1\\u0026br=6662\\u0026bt=6662\\u0026cs=2\\u0026ds=10\\u0026ft=khyHAB1UiiuGzJrZ~~OC~49Zyo3nOz7HQNaLpMyC6LZjrKQ2B22E1J6kcKb2oPd.o~\\u0026mime_type=video_mp4\\u0026qs=15\\u0026rc=ODNoPGk2MzRnNDg7NDdnNUBpM3ZzbXA5cjNyNjMzbGkzNEAzMy41MzU1XzMxNTY2Ll9jYSNmYy1tMmQ0b2xhLS1kLWJzcw%3D%3D\\u0026btag=c0000e00028000\\u0026cquery=100w_100B_100x_100z_100o\\u0026dy_q=1760866258\\u0026feature_id=10cf95ef75b4f3e7eac623e4ea0ea691\\u0026l=20251019173058DF8022814E036CA4C097",
"body": null,
"method": "GET",
"mode": "cors",
"credentials": "omit"
});
import { pipeline, Readable } from 'stream';
import { promisify } from 'util';
import { initBucket } from "./lib/minio";
const streamPipeline = promisify(pipeline);
if(!response.body) {
throw new Error("No response body");
}
// 将 Web ReadableStream 转换为 Node.js Readable
const nodeStream = Readable.fromWeb(response.body as any);
const contentLength = response.headers.get('content-length');
await uploadFileStream(nodeStream, 'test-video.mp4', Number(contentLength));
export { };

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}