From 28447c2e651d21f784a8bb52e9c296343025f2a9 Mon Sep 17 00:00:00 2001 From: iye <1713042409@qq.com> Date: Tue, 12 May 2026 09:39:21 +0800 Subject: [PATCH] feat(home): add HeroBanner with PV video + ArtistFilters + 35-artist grid/list views --- src/app/page.tsx | 270 ++++++++++++++++++++----------- src/components/ArtistFilters.tsx | 154 ++++++++++++++++++ src/components/HeroBanner.tsx | 215 ++++++++++++++++++++++++ 3 files changed, 542 insertions(+), 97 deletions(-) create mode 100644 src/components/ArtistFilters.tsx create mode 100644 src/components/HeroBanner.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 3767469..6b1a553 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,128 +1,127 @@ "use client"; -import { useState } from "react"; -import { Heart, Play, Share2 } from "lucide-react"; -import Button from "@/components/ui/Button"; -import Countdown from "@/components/ui/Countdown"; -import ArtistCard from "@/components/cards/ArtistCard"; +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import HeroBanner from "@/components/HeroBanner"; import Top12Bar from "@/components/Top12Bar"; +import ArtistCard from "@/components/cards/ArtistCard"; +import ArtistFilters, { + type TagFilter, + type ViewMode, +} from "@/components/ArtistFilters"; import VoteModal from "@/components/VoteModal"; -import { ARTISTS, getActivityEndTime } from "@/lib/mock-data"; +import ArtistPortrait from "@/components/cards/ArtistPortrait"; +import { ARTISTS, sortArtists, getActivityEndTime } from "@/lib/mock-data"; +import type { SortKey } from "@/lib/mock-data"; import type { Artist } from "@/types/artist"; +import { cn } from "@/lib/cn"; export default function Home() { const [voteTarget, setVoteTarget] = useState(null); - const endTime = getActivityEndTime(); + const [sortKey, setSortKey] = useState("votes"); + const [tagFilter, setTagFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [view, setView] = useState("grid"); - const handleVote = (artist: Artist, count: number) => { - // TODO: 接入实际投票 API(Phase 10) + const endTime = useMemo(() => getActivityEndTime(), []); + + const visibleArtists = useMemo(() => { + let list = [...ARTISTS]; + if (tagFilter !== "all") { + list = list.filter((a) => a.tags.includes(tagFilter)); + } + if (search.trim()) { + const q = search.trim().toLowerCase(); + list = list.filter( + (a) => + a.name.toLowerCase().includes(q) || + a.enName.toLowerCase().includes(q) || + a.no.includes(q), + ); + } + return sortArtists(list, sortKey); + }, [sortKey, tagFilter, search]); + + const handleVote = async (artist: Artist, count: number) => { + // TODO: Phase 10 接入真实投票 API + await new Promise((r) => setTimeout(r, 400)); console.log(`Vote: ${artist.name} × ${count}`); setVoteTarget(null); }; return ( -
- {/* Hero · Logo + slogan */} -
-

- Top 12 · Virtual Idol Debut Project -

-

- Cyber - - ✦ - - Star -

-

- 虚拟偶像出道企划 -

- - {/* Countdown */} -
- -
- - {/* CTA */} -
- - -
+ <> + {/* Hero Banner */} +
+
{/* Top12 实时榜条 */} -
-
-

- 🏆 Top 12 · 实时出道位 +
+
+

+ 🏆 + Top 12 · 实时出道位

- - 查看完整榜单 › - + 查看完整榜单 + +
- {/* 艺人卡片网格示例 */} -
-

- ✦ 候选人阵容 -

-
- {ARTISTS.slice(0, 10).map((a) => ( - - ))} + {/* 候选人阵容 */} +
+
+
+

+ Candidates · 35 Idols +

+

+ ✦ 候选人阵容 +

+
+

+ 共 {visibleArtists.length}{" "} + 位艺人 +

-
- {/* Component sandbox */} -
-

- Phase 4 · Component Sandbox -

-
-
-

- Button Variants -

-
- - - - - -
+ + + {/* Artist list */} + {visibleArtists.length === 0 ? ( + + ) : view === "grid" ? ( +
+ {visibleArtists.map((a) => ( + + ))}
-
-

- Countdown · Compact -

- -

- 用于导航 / Hero 角标 -

+ ) : ( +
+ {visibleArtists.map((a) => ( + + ))}
-
+ )}
{/* 投票弹窗 */} @@ -131,6 +130,83 @@ export default function Home() { onClose={() => setVoteTarget(null)} onConfirm={handleVote} /> + + ); +} + +/** 列表视图行 */ +function ArtistListRow({ + artist, + onVote, +}: { + artist: Artist; + onVote: (a: Artist) => void; +}) { + const inTop12 = artist.rank <= 12; + return ( +
+
+ #{artist.rank} +
+ +
+ +
+
+
+ {artist.name}{" "} + + · {artist.enName} + +
+
+ No.{artist.no} · {artist.slogan} +
+
+ +
+
+ {(artist.votes / 10000).toFixed(1)}w +
+
+
+ +
+ ); +} + +function EmptyState() { + return ( +
+

+ No Match +

+

未找到匹配的艺人,试试换个关键词或筛选标签

); } diff --git a/src/components/ArtistFilters.tsx b/src/components/ArtistFilters.tsx new file mode 100644 index 0000000..e2c68e7 --- /dev/null +++ b/src/components/ArtistFilters.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Search, LayoutGrid, List } from "lucide-react"; +import { cn } from "@/lib/cn"; +import type { ArtistTag } from "@/types/artist"; +import type { SortKey } from "@/lib/mock-data"; + +export type ViewMode = "grid" | "list"; +export type TagFilter = ArtistTag | "all"; + +interface ArtistFiltersProps { + sortKey: SortKey; + onSortChange: (key: SortKey) => void; + tagFilter: TagFilter; + onTagChange: (tag: TagFilter) => void; + search: string; + onSearchChange: (q: string) => void; + view: ViewMode; + onViewChange: (v: ViewMode) => void; +} + +const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: "votes", label: "票数" }, + { key: "no", label: "编号" }, + { key: "recent", label: "最近活跃" }, +]; + +const TAG_OPTIONS: { key: TagFilter; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "dance", label: "舞蹈担当" }, + { key: "vocal", label: "声乐担当" }, + { key: "rap", label: "Rap 担当" }, + { key: "all-rounder", label: "全能型" }, +]; + +export default function ArtistFilters({ + sortKey, + onSortChange, + tagFilter, + onTagChange, + search, + onSearchChange, + view, + onViewChange, +}: ArtistFiltersProps) { + return ( +
+ {/* 排序 */} + + 排序 + +
+ {SORT_OPTIONS.map((opt) => ( + onSortChange(opt.key)} + > + {opt.label} + + ))} +
+ + + + {/* 标签 */} + + 标签 + +
+ {TAG_OPTIONS.map((opt) => ( + onTagChange(opt.key)} + > + {opt.label} + + ))} +
+ + {/* 搜索 + 视图切换:靠右 */} +
+ + +
+ + +
+
+
+ ); +} + +function FilterTag({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/HeroBanner.tsx b/src/components/HeroBanner.tsx new file mode 100644 index 0000000..ae89e23 --- /dev/null +++ b/src/components/HeroBanner.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Play, + Pause, + Volume2, + VolumeX, + Maximize2, + ChevronDown, +} from "lucide-react"; +import Countdown from "./ui/Countdown"; +import { cn } from "@/lib/cn"; + +interface HeroBannerProps { + /** PV 视频 URL(MP4) */ + videoSrc?: string; + /** 视频封面图 */ + poster?: string; + /** 活动结束时间 */ + endTime: Date | string | number; + /** 标题主行(默认 CYBER STAR) */ + title?: string; + /** 副标题 */ + subtitle?: string; + /** 上标小字 */ + eyebrow?: string; + className?: string; +} + +export default function HeroBanner({ + videoSrc, + poster, + endTime, + title = "CYBER STAR", + subtitle = "虚拟偶像出道企划", + eyebrow = "Top 12 · Virtual Idol Debut Project", + className, +}: HeroBannerProps) { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(true); + const [isMuted, setIsMuted] = useState(true); + const [hasInteracted, setHasInteracted] = useState(false); + + // 自动播放(静音) + useEffect(() => { + const v = videoRef.current; + if (!v || !videoSrc) return; + v.play().catch(() => setIsPlaying(false)); + }, [videoSrc]); + + const togglePlay = () => { + const v = videoRef.current; + if (!v) return; + if (v.paused) { + v.play(); + setIsPlaying(true); + } else { + v.pause(); + setIsPlaying(false); + } + setHasInteracted(true); + }; + + const toggleMute = () => { + const v = videoRef.current; + if (!v) return; + v.muted = !v.muted; + setIsMuted(v.muted); + setHasInteracted(true); + }; + + const goFullscreen = () => { + videoRef.current?.requestFullscreen?.(); + }; + + const scrollToContent = () => { + window.scrollBy({ top: window.innerHeight * 0.85, behavior: "smooth" }); + }; + + return ( +
+ {/* 背景视频 / 渐变占位 */} + {videoSrc ? ( +
+ ); +}