From 674c202264e3957189529209c70089066e20506f Mon Sep 17 00:00:00 2001 From: feie9454 Date: Sat, 29 Nov 2025 21:56:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=9C=E8=80=85=E4=B8=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/author/[secUid]/feed/route.ts | 67 ++++++++++ app/api/feed/route.ts | 4 +- app/author/[secUid]/page.tsx | 123 ++++++++++++++++++ .../[awemeId]/components/MediaControls.tsx | 19 ++- app/aweme/[awemeId]/page.tsx | 6 +- app/aweme/[awemeId]/types.ts | 2 +- app/components/FeedMasonry.tsx | 45 +++++-- app/components/SearchBox.tsx | 2 +- app/page.tsx | 38 ++++-- app/types/feed.ts | 2 +- next.config.ts | 8 ++ 11 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 app/api/author/[secUid]/feed/route.ts create mode 100644 app/author/[secUid]/page.tsx diff --git a/app/api/author/[secUid]/feed/route.ts b/app/api/author/[secUid]/feed/route.ts new file mode 100644 index 0000000..aac8924 --- /dev/null +++ b/app/api/author/[secUid]/feed/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import type { FeedItem, FeedResponse } from '@/app/types/feed'; +import { getFileUrl } from '@/lib/minio'; + +export async function GET(req: NextRequest, { params }: { params: Promise<{ secUid: string }> }) { + const secUid = (await params).secUid; + 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: { + authorId: secUid, + ...(before ? { created_at: { lt: before } } : {}) + }, + orderBy: { created_at: 'desc' }, + take: limit, + include: { author: true }, + }), + prisma.imagePost.findMany({ + where: { + authorId: secUid, + ...(before ? { created_at: { lt: before } } : {}) + }, + 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: getFileUrl(v.video_url), + cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'), + width: v.width ?? null, + height: v.height ?? null, + author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, + likes: Number(v.digg_count) + })), + ...posts.map((p) => ({ + type: "image" as const, + aweme_id: p.aweme_id, + created_at: p.created_at, + desc: p.desc, + cover_url: getFileUrl(p.images?.[0]?.url ?? null), + width: p.images?.[0]?.width ?? null, + height: p.images?.[0]?.height ?? null, + author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, + likes: Number(p.digg_count) + })), + ].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) + .slice(0, limit); + + const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null; + const payload: FeedResponse = { items: merged, nextCursor }; + return NextResponse.json(payload); +} diff --git a/app/api/feed/route.ts b/app/api/feed/route.ts index 7989cbd..7c5a45f 100644 --- a/app/api/feed/route.ts +++ b/app/api/feed/route.ts @@ -41,7 +41,7 @@ export async function GET(req: NextRequest) { cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'), width: v.width ?? null, height: v.height ?? null, - author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? '') }, + author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, likes: Number(v.digg_count) })), ...posts.map((p) => ({ @@ -52,7 +52,7 @@ export async function GET(req: NextRequest) { cover_url: getFileUrl(p.images?.[0]?.url ?? null), width: p.images?.[0]?.width ?? null, height: p.images?.[0]?.height ?? null, - author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? '') }, + author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, likes: Number(p.digg_count) })), ].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) diff --git a/app/author/[secUid]/page.tsx b/app/author/[secUid]/page.tsx new file mode 100644 index 0000000..aae069d --- /dev/null +++ b/app/author/[secUid]/page.tsx @@ -0,0 +1,123 @@ +import { prisma } from "@/lib/prisma"; +import { getFileUrl } from "@/lib/minio"; +import FeedMasonry from "@/app/components/FeedMasonry"; +import BackButton from "@/app/components/BackButton"; +import { FeedItem } from "@/app/types/feed"; +import { notFound } from "next/navigation"; +import Image from "next/image"; + +export default async function AuthorPage({ params }: { params: Promise<{ secUid: string }> }) { + const secUid = (await params).secUid; + + const author = await prisma.author.findUnique({ + where: { sec_uid: secUid }, + }); + + if (!author) { + notFound(); + } + + // Initial feed fetch + const limit = 24; + const [videos, posts] = await Promise.all([ + prisma.video.findMany({ + where: { authorId: secUid }, + orderBy: { created_at: 'desc' }, + take: limit, + include: { author: true }, + }), + prisma.imagePost.findMany({ + where: { authorId: secUid }, + orderBy: { created_at: 'desc' }, + take: limit, + include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } }, + }), + ]); + + const initialItems: FeedItem[] = [ + ...videos.map((v) => ({ + type: "video" as const, + aweme_id: v.aweme_id, + created_at: v.created_at, + desc: v.desc, + video_url: getFileUrl(v.video_url), + cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'), + width: v.width ?? null, + height: v.height ?? null, + author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, + likes: Number(v.digg_count) + })), + ...posts.map((p) => ({ + type: "image" as const, + aweme_id: p.aweme_id, + created_at: p.created_at, + desc: p.desc, + cover_url: getFileUrl(p.images?.[0]?.url ?? null), + width: p.images?.[0]?.width ?? null, + height: p.images?.[0]?.height ?? null, + author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, + likes: Number(p.digg_count) + })), + ].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) + .slice(0, limit); + + const initialCursor = initialItems.length > 0 ? new Date(initialItems[initialItems.length - 1].created_at as any).toISOString() : null; + + return ( +
+
+ +

{author.nickname}

+
+ +
+ {/* Author Profile Header */} +
+
+ {author.nickname} +
+ +
+
+

{author.nickname}

+

+ 抖音号:{author.unique_id || author.short_id || '未知'} +

+
+ + {author.signature && ( +

+ {author.signature} +

+ )} + +
+
+ {Number(author.total_favorited).toLocaleString()} + 获赞 +
+
+ {Number(author.follower_count).toLocaleString()} + 粉丝 +
+
+
+
+ +
+

作品

+ +
+
+
+ ); +} diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index b387780..6234356 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { ArrowDownUp, Download, @@ -91,9 +92,21 @@ export function MediaControls({ return (
{/* 描述行 */} -
- - {author.nickname} +
+ {author.sec_uid ? ( + + + {author.nickname} + + ) : ( +
+ + {author.nickname} +
+ )} · { if (isVideo) { const aweme = video! diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index a49580a..9b14304 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -1,4 +1,4 @@ -export type User = { nickname: string; avatar_url: string | null }; +export type User = { nickname: string; avatar_url: string | null; sec_uid?: string }; export type Comment = { cid: string; diff --git a/app/components/FeedMasonry.tsx b/app/components/FeedMasonry.tsx index 1954bdb..b28e265 100644 --- a/app/components/FeedMasonry.tsx +++ b/app/components/FeedMasonry.tsx @@ -8,9 +8,10 @@ import type { FeedItem, FeedResponse } from '@/app/types/feed'; type Props = { initialItems: FeedItem[]; initialCursor: string | null; + fetchUrl?: string; }; -export default function FeedMasonry({ initialItems, initialCursor }: Props) { +export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: Props) { // 哨兵与容器 const [cursor, setCursor] = useState(initialCursor); const [loading, setLoading] = useState(false); @@ -91,7 +92,8 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) { 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' }); + const url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`; + const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: FeedResponse = await res.json(); // 将新数据按最短列分配 @@ -137,8 +139,8 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) { }, [fetchMore]); const renderCard = useCallback((item: FeedItem) => ( - -
+
+
+ -
-
- {item.author.avatar_url ? ( - avatar - ) : null} +
+ {item.author.sec_uid ? ( + +
+ {item.author.avatar_url ? ( + avatar + ) : null} +
+ {item.author.nickname} + + ) : ( +
+
+ {item.author.avatar_url ? ( + avatar + ) : null} +
+ {item.author.nickname}
- {item.author.nickname} - {item.likes} -
- - + )} + + {item.likes} + +
+ ), []); return ( diff --git a/app/components/SearchBox.tsx b/app/components/SearchBox.tsx index 44e46ec..cf6f24f 100644 --- a/app/components/SearchBox.tsx +++ b/app/components/SearchBox.tsx @@ -16,7 +16,7 @@ export default function SearchBox() { }; return ( -
+ ({ @@ -47,7 +47,7 @@ export default async function Home() { cover_url: getFileUrl(p.images?.[0]?.url ?? null), width: p.images?.[0]?.width ?? null, height: p.images?.[0]?.height ?? null, - author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? '') }, + author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, likes: Number(p.digg_count) })), ] @@ -55,19 +55,29 @@ export default async function Home() { .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)); return ( -
-
-

- 作品集 -

- -
+
+
+ {/* 装饰背景 */} +
+
+
- {(() => { - const initial = feed.slice(0, 24); - const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; - return ; - })()} +

+ Douyin Archive +

+ +
+ +
+ +
+ {(() => { + const initial = feed.slice(0, 24); + const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; + return ; + })()} +
+
); } diff --git a/app/types/feed.ts b/app/types/feed.ts index 9fd39e2..2505ed1 100644 --- a/app/types/feed.ts +++ b/app/types/feed.ts @@ -6,7 +6,7 @@ export type FeedItem = type: "image"; }) & { likes: number; - author: { nickname: string; avatar_url: string | null }; + author: { nickname: string; avatar_url: string | null; sec_uid?: string }; aweme_id: string; created_at: Date | string; desc: string; diff --git a/next.config.ts b/next.config.ts index 2f9491e..03e32f7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,14 @@ const nextConfig: NextConfig = { }, }, /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 's3l.xn--876a.net', + }, + ], + }, }; export default nextConfig;