From 5f06b5122bc4386105faf38a3f9e6f5acf9cc9a6 Mon Sep 17 00:00:00 2001 From: iye <1713042409@qq.com> Date: Tue, 12 May 2026 09:42:01 +0800 Subject: [PATCH] feat(artist): dynamic /artist/[id] page with hero, 15s video, gallery lightbox, bio and floating vote --- src/app/artist/[id]/page.tsx | 32 +++ src/components/FloatingVoteButton.tsx | 46 ++++ src/components/artist/ArtistDetailContent.tsx | 241 ++++++++++++++++++ src/components/artist/PerformanceGallery.tsx | 198 ++++++++++++++ src/components/artist/PerformanceVideo.tsx | 146 +++++++++++ src/components/artist/RankCard.tsx | 82 ++++++ 6 files changed, 745 insertions(+) create mode 100644 src/app/artist/[id]/page.tsx create mode 100644 src/components/FloatingVoteButton.tsx create mode 100644 src/components/artist/ArtistDetailContent.tsx create mode 100644 src/components/artist/PerformanceGallery.tsx create mode 100644 src/components/artist/PerformanceVideo.tsx create mode 100644 src/components/artist/RankCard.tsx diff --git a/src/app/artist/[id]/page.tsx b/src/app/artist/[id]/page.tsx new file mode 100644 index 0000000..1d9939a --- /dev/null +++ b/src/app/artist/[id]/page.tsx @@ -0,0 +1,32 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { ARTISTS, getArtist } from "@/lib/mock-data"; +import ArtistDetailContent from "@/components/artist/ArtistDetailContent"; + +interface ArtistPageProps { + params: Promise<{ id: string }>; +} + +export async function generateStaticParams() { + return ARTISTS.map((a) => ({ id: a.id })); +} + +export async function generateMetadata({ + params, +}: ArtistPageProps): Promise { + const { id } = await params; + const artist = getArtist(id); + if (!artist) return { title: "艺人不存在 · CYBER STAR" }; + + return { + title: `${artist.name} · ${artist.enName} · CYBER STAR`, + description: artist.bio.slice(0, 120), + }; +} + +export default async function ArtistPage({ params }: ArtistPageProps) { + const { id } = await params; + const artist = getArtist(id); + if (!artist) notFound(); + return ; +} diff --git a/src/components/FloatingVoteButton.tsx b/src/components/FloatingVoteButton.tsx new file mode 100644 index 0000000..f18a580 --- /dev/null +++ b/src/components/FloatingVoteButton.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Heart } from "lucide-react"; +import { cn } from "@/lib/cn"; + +interface FloatingVoteButtonProps { + onClick: () => void; + /** 显示前的滚动阈值(px) */ + threshold?: number; + className?: string; +} + +export default function FloatingVoteButton({ + onClick, + threshold = 300, + className, +}: FloatingVoteButtonProps) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const handler = () => setVisible(window.scrollY > threshold); + handler(); + window.addEventListener("scroll", handler, { passive: true }); + return () => window.removeEventListener("scroll", handler); + }, [threshold]); + + return ( + + ); +} diff --git a/src/components/artist/ArtistDetailContent.tsx b/src/components/artist/ArtistDetailContent.tsx new file mode 100644 index 0000000..5f48c86 --- /dev/null +++ b/src/components/artist/ArtistDetailContent.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ChevronLeft, Heart, Share2 } from "lucide-react"; +import type { Artist } from "@/types/artist"; +import { TAG_LABEL } from "@/types/artist"; +import ArtistPortrait from "@/components/cards/ArtistPortrait"; +import VoteModal from "@/components/VoteModal"; +import Button from "@/components/ui/Button"; +import RankCard from "./RankCard"; +import PerformanceVideo from "./PerformanceVideo"; +import PerformanceGallery from "./PerformanceGallery"; +import FloatingVoteButton from "@/components/FloatingVoteButton"; + +interface ArtistDetailContentProps { + artist: Artist; + allArtists: Artist[]; +} + +export default function ArtistDetailContent({ + artist, + allArtists, +}: ArtistDetailContentProps) { + const [voteOpen, setVoteOpen] = useState(false); + + const handleVote = async (a: Artist, count: number) => { + await new Promise((r) => setTimeout(r, 400)); + console.log(`Vote: ${a.name} × ${count}`); + setVoteOpen(false); + }; + + return ( + <> + {/* 简化面包屑 */} +
+
+ + + 返回 + + / + 首页 + / + 艺人详情 + / + {artist.name} + +
+
+ + {/* Hero 区 */} +
+
+ {/* 立绘 */} +
+ + {/* 应援色装饰条 */} +
+ + + 应援色 + + + {artist.themeColor} + +
+
+ + {/* 信息区 */} +
+
+
+ No.{artist.no} +
+

+ {artist.name} +

+

+ {artist.enName} +

+
+ + {/* 标签 */} +
+ {artist.tags.map((t) => ( + + {TAG_LABEL[t]} + + ))} +
+ + {/* 元信息 */} +
+ + + +
+ + {/* 排名卡 */} + + + {/* CTA */} +
+ + +
+
+
+
+ + {/* 15s 表演视频 */} +
+ + +

+ ⚠ 视频不会自动播放,避免流量浪费 +

+
+ + {/* 表演图片 */} +
+ + +
+ + {/* 详细简介 */} +
+ +
+

+ {artist.bio} +

+
+ + + +
+
+
+ + {/* 浮动投票按钮 */} + setVoteOpen(true)} /> + + {/* 投票弹窗 */} + setVoteOpen(false)} + onConfirm={handleVote} + /> + + ); +} + +function MetaCell({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + +function BioMeta({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function SectionHeading({ + title, + subtitle, +}: { + title: string; + subtitle: string; +}) { + return ( +
+

+ {subtitle} +

+

+ ✦ {title} +

+
+ ); +} diff --git a/src/components/artist/PerformanceGallery.tsx b/src/components/artist/PerformanceGallery.tsx new file mode 100644 index 0000000..29bd38e --- /dev/null +++ b/src/components/artist/PerformanceGallery.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { X, ChevronLeft, ChevronRight } from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import Image from "next/image"; +import { cn } from "@/lib/cn"; + +interface PerformanceGalleryProps { + images: string[]; + /** 当无真实图时,用此颜色生成占位 */ + themeColor?: string; + /** 占位标签(如 "定妆照"、"表演中") */ + placeholderLabels?: string[]; +} + +const DEFAULT_LABELS = ["定妆照", "表演中", "幕后花絮", "舞台 1", "舞台 2", "未公开"]; + +export default function PerformanceGallery({ + images, + themeColor = "#8b5cf6", + placeholderLabels = DEFAULT_LABELS, +}: PerformanceGalleryProps) { + const [lightboxIndex, setLightboxIndex] = useState(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + // ESC 关闭 + useEffect(() => { + if (lightboxIndex == null) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") setLightboxIndex(null); + if (e.key === "ArrowLeft") + setLightboxIndex((i) => (i! > 0 ? i! - 1 : images.length - 1)); + if (e.key === "ArrowRight") + setLightboxIndex((i) => (i! < images.length - 1 ? i! + 1 : 0)); + }; + window.addEventListener("keydown", handler); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", handler); + document.body.style.overflow = prev; + }; + }, [lightboxIndex, images.length]); + + return ( + <> +
+ {images.map((src, i) => ( + + ))} +
+ +

点击图片打开大图查看

+ + {/* Lightbox */} + {mounted && + createPortal( + + {lightboxIndex !== null && ( + + + + {/* 左右切换 */} + {images.length > 1 && ( + <> + + + + )} + + {/* 图片本体 */} + + {images[lightboxIndex] ? ( + {`表演图 + ) : ( +
+
+ + ✦ + +

+ {placeholderLabels[lightboxIndex] || + `Image ${lightboxIndex + 1}`} +

+
+
+ )} +
+ + {/* 索引 */} +
+ {lightboxIndex + 1} / {images.length} +
+
+ )} +
, + document.body, + )} + + ); +} diff --git a/src/components/artist/PerformanceVideo.tsx b/src/components/artist/PerformanceVideo.tsx new file mode 100644 index 0000000..1623a98 --- /dev/null +++ b/src/components/artist/PerformanceVideo.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Play, Pause, Volume2, VolumeX, Maximize2 } from "lucide-react"; +import { cn } from "@/lib/cn"; + +interface PerformanceVideoProps { + src?: string; + poster?: string; + duration?: string; + themeColor?: string; + className?: string; +} + +export default function PerformanceVideo({ + src, + poster, + duration = "00:15", + themeColor = "#8b5cf6", + className, +}: PerformanceVideoProps) { + const videoRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [muted, setMuted] = useState(false); + + const togglePlay = () => { + const v = videoRef.current; + if (!v) return; + if (v.paused) { + v.play(); + setPlaying(true); + } else { + v.pause(); + setPlaying(false); + } + }; + + const toggleMute = () => { + const v = videoRef.current; + if (!v) return; + v.muted = !v.muted; + setMuted(v.muted); + }; + + const goFullscreen = () => { + videoRef.current?.requestFullscreen?.(); + }; + + return ( +
+ {src ? ( +
+ ); +} diff --git a/src/components/artist/RankCard.tsx b/src/components/artist/RankCard.tsx new file mode 100644 index 0000000..e01390c --- /dev/null +++ b/src/components/artist/RankCard.tsx @@ -0,0 +1,82 @@ +import type { Artist } from "@/types/artist"; +import { cn } from "@/lib/cn"; + +interface RankCardProps { + artist: Artist; + /** 全榜单(用于计算与上下名的差距) */ + allArtists: Artist[]; + className?: string; +} + +export default function RankCard({ artist, allArtists, className }: RankCardProps) { + // 按当前票数排序后定位艺人 + const sorted = [...allArtists].sort((a, b) => b.votes - a.votes); + const idx = sorted.findIndex((a) => a.id === artist.id); + const prev = idx > 0 ? sorted[idx - 1] : undefined; + const next = idx < sorted.length - 1 ? sorted[idx + 1] : undefined; + + const leadOver = next ? artist.votes - next.votes : null; + const trailBehind = prev ? prev.votes - artist.votes : null; + + const isFirst = artist.rank === 1; + const inTop12 = artist.rank <= 12; + + return ( +
+ {/* 当前排名 */} +
+
+ 当前排名 +
+
+ + #{artist.rank} + + + {(artist.votes / 10000).toFixed(1)}w 票 + +
+ {inTop12 && ( + + ✦ 出道位 + + )} +
+ + {/* 差距信息 */} +
+ {isFirst && leadOver != null ? ( + <> +
+ 领先第二名 +
+
+ +{leadOver.toLocaleString()} +
+
+ + ) : trailBehind != null ? ( + <> +
+ 距上一名 +
+
+ −{trailBehind.toLocaleString()} +
+
+ + ) : null} +
+
+ ); +}