diff --git a/package.json b/package.json index 8cb5036..811a2a7 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,13 @@ "lint": "eslint" }, "dependencies": { + "clsx": "^2.1.1", + "framer-motion": "^12.38.0", + "lucide-react": "^1.14.0", "next": "16.2.6", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6e27e6..73ac86d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + clsx: + specifier: ^2.1.1 + version: 2.1.1 + framer-motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + lucide-react: + specifier: ^1.14.0 + version: 1.14.0(react@19.2.4) next: specifier: 16.2.6 version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -17,6 +26,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -864,6 +876,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1167,6 +1183,20 @@ packages: resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==} engines: {node: '>=14'} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1553,6 +1583,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.14.0: + resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1586,6 +1621,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1932,6 +1973,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} @@ -2829,6 +2873,8 @@ snapshots: client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3276,6 +3322,15 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3640,6 +3695,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.14.0(react@19.2.4): + dependencies: + react: 19.2.4 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3669,6 +3728,12 @@ snapshots: minipass@7.1.3: {} + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + ms@2.1.3: {} nanoid@3.3.12: {} @@ -4089,6 +4154,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwind-merge@3.6.0: {} + tailwindcss@4.3.0: {} tapable@2.3.3: {} diff --git a/src/app/page.tsx b/src/app/page.tsx index d5c23b5..3767469 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,88 +1,136 @@ +"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 Top12Bar from "@/components/Top12Bar"; +import VoteModal from "@/components/VoteModal"; +import { ARTISTS, getActivityEndTime } from "@/lib/mock-data"; +import type { Artist } from "@/types/artist"; + export default function Home() { + const [voteTarget, setVoteTarget] = useState(null); + const endTime = getActivityEndTime(); + + const handleVote = (artist: Artist, count: number) => { + // TODO: 接入实际投票 API(Phase 10) + console.log(`Vote: ${artist.name} × ${count}`); + setVoteTarget(null); + }; + return ( -
-
- {/* Logo */} +
+ {/* Hero · Logo + slogan */} +
+

+ Top 12 · Virtual Idol Debut Project +

Cyber - + Star

- - {/* Subtitle */} -

- Virtual Idol Debut Project -

-

- 虚拟偶像 Top12 出道企划 +

+ 虚拟偶像出道企划

- {/* Status badge */} -
- - - Phase 3 · Layout Ready - + {/* Countdown */} +
+
- {/* Theme verification swatches */} -
- {[ - { name: "purple-300", className: "bg-purple-300" }, - { name: "purple-500", className: "bg-purple-500" }, - { name: "purple-700", className: "bg-purple-700" }, - { name: "magenta", className: "bg-magenta" }, - { name: "pink-400", className: "bg-pink-400" }, - { name: "cyan-400", className: "bg-cyan-400" }, - ].map((c) => ( -
-
-

{c.name}

-
+ {/* CTA */} +
+ + +
+
+ + {/* Top12 实时榜条 */} +
+
+

+ 🏆 Top 12 · 实时出道位 +

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

+ ✦ 候选人阵容 +

+
+ {ARTISTS.slice(0, 10).map((a) => ( + ))}
+
- {/* Font verification */} -
-
-

- Logo Font · Megrim + {/* Component sandbox */} +

+

+ Phase 4 · Component Sandbox +

+
+
+

+ Button Variants

-

Cyber Star

+
+ + + + + +
-
-

- Display Font · Audiowide +

+

+ Countdown · Compact

-

VOTE 2026

-
-
-

- Label Font · Cinzel + +

+ 用于导航 / Hero 角标

-

- DEBUT PROJECT -

-
-
-

- Body Font · Inter -

-

虚拟偶像出道企划 · Aria

+
-

- Phase 4 → Core Components 即将开始 -

-
-
+ {/* 投票弹窗 */} + setVoteTarget(null)} + onConfirm={handleVote} + /> + ); } diff --git a/src/components/Top12Bar.tsx b/src/components/Top12Bar.tsx new file mode 100644 index 0000000..edb7219 --- /dev/null +++ b/src/components/Top12Bar.tsx @@ -0,0 +1,135 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import type { Artist } from "@/types/artist"; +import { getRankCategory } from "@/types/artist"; +import { cn } from "@/lib/cn"; +import ArtistPortrait from "./cards/ArtistPortrait"; + +interface Top12BarProps { + artists: Artist[]; + /** 点击 VOTE NOW 触发的回调(不传则跳转 /vote) */ + onVoteNow?: () => void; +} + +const RANK_BORDER = { + gold: "border-[#fcd34d] shadow-[0_0_16px_rgba(252,211,77,0.5)]", + silver: "border-[#c4ccd8] shadow-[0_0_12px_rgba(196,204,216,0.4)]", + bronze: "border-[#cd7f32] shadow-[0_0_12px_rgba(205,127,50,0.4)]", + top12: "border-purple-500 shadow-[0_0_10px_rgba(139,92,246,0.3)]", + candidate: "border-white/15", +} as const; + +const BADGE_BG = { + gold: "bg-[#fcd34d] text-black", + silver: "bg-[#c4ccd8] text-black", + bronze: "bg-[#cd7f32] text-white", + top12: "bg-purple-600 text-white", + candidate: "bg-white/10 text-white/70", +} as const; + +export default function Top12Bar({ artists, onVoteNow }: Top12BarProps) { + return ( +
+ {/* 头像横滚 */} +
+ {artists.slice(0, 12).map((artist) => { + const cat = getRankCategory(artist.rank); + return ( + +
+ + {/* 编号 */} +
+ {artist.no.slice(-2)} +
+ {/* 排名小角标 */} +
+ {artist.rank} +
+
+

+ {artist.enName} +

+ + ); + })} +
+ + {/* VOTE NOW 侧栏面板 */} + +
+ ); +} + +function VotePanel({ onClick }: { onClick?: () => void }) { + const content = ( +
+ {/* 高光装饰 */} +
+ {/* 装饰星点 */} + + +
+
+ VOTE +
+ NOW +
+
+ 为偶像应援 +
+
+ +
+ + + +
+
+ ); + + if (onClick) { + return ( + + ); + } + return ( + + {content} + + ); +} diff --git a/src/components/VoteModal.tsx b/src/components/VoteModal.tsx new file mode 100644 index 0000000..0f3d4a4 --- /dev/null +++ b/src/components/VoteModal.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { X, Heart, Gem } from "lucide-react"; +import type { Artist } from "@/types/artist"; +import { cn } from "@/lib/cn"; +import Button from "./ui/Button"; +import ArtistPortrait from "./cards/ArtistPortrait"; + +interface VoteModalProps { + /** 当前要投票的艺人,传 null 关闭弹窗 */ + artist: Artist | null; + /** 今日剩余票数 */ + remainingVotes?: number; + /** 今日已用票数 */ + usedVotes?: number; + /** 单艺人每日上限 */ + perArtistLimit?: number; + /** 关闭弹窗 */ + onClose: () => void; + /** 确认投票 */ + onConfirm: (artist: Artist, count: number) => void | Promise; +} + +const VOTE_OPTIONS: Array = [1, 3, 5, "ALL"]; + +export default function VoteModal({ + artist, + remainingVotes = 12, + usedVotes = 0, + perArtistLimit = 3, + onClose, + onConfirm, +}: VoteModalProps) { + const open = artist != null; + const [selected, setSelected] = useState(3); + const [loading, setLoading] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + // 打开时重置默认选择 + useEffect(() => { + if (open) { + setSelected(3); + setLoading(false); + } + }, [open]); + + // ESC 关闭 + body 锁滚 + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + 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; + }; + }, [open, onClose]); + + const maxVotes = Math.min(remainingVotes, perArtistLimit); + const actualCount = selected === "ALL" ? maxVotes : Math.min(selected, maxVotes); + const canVote = actualCount > 0 && !loading; + + const handleConfirm = useCallback(async () => { + if (!artist || !canVote) return; + setLoading(true); + try { + await onConfirm(artist, actualCount); + } finally { + setLoading(false); + } + }, [artist, actualCount, canVote, onConfirm]); + + if (!mounted) return null; + + return createPortal( + + {open && artist && ( + + {/* 遮罩 */} + + + {/* 头像 */} +
+ +
+ + {/* 标题 */} +
+
+ 为 {artist.name} 投票 +
+
+ No.{artist.no} · Current Rank #{artist.rank} +
+
+ + {/* 票数选择 */} +
选择投票数:
+
+ {VOTE_OPTIONS.map((opt) => { + const isAll = opt === "ALL"; + const optNum = isAll ? maxVotes : (opt as number); + const disabled = optNum > maxVotes || maxVotes === 0; + const active = selected === opt; + return ( + + ); + })} +
+ + {/* 票数余额 */} +
+ + + 今日剩余:{remainingVotes} 票 + + | + 已用:{usedVotes} 票 +
+ + {/* 确认按钮 */} + + + {/* 提示 */} +

+ 每日 12 票 · 每艺人每日最多 {perArtistLimit} 票 +

+
+ + )} +
, + document.body, + ); +} diff --git a/src/components/cards/ArtistCard.tsx b/src/components/cards/ArtistCard.tsx new file mode 100644 index 0000000..a21bb72 --- /dev/null +++ b/src/components/cards/ArtistCard.tsx @@ -0,0 +1,106 @@ +import Link from "next/link"; +import type { Artist } from "@/types/artist"; +import { cn } from "@/lib/cn"; +import ArtistPortrait from "./ArtistPortrait"; + +interface ArtistCardProps { + artist: Artist; + /** 触发投票(不传则跳详情页) */ + onVote?: (artist: Artist) => void; + className?: string; +} + +function formatVotes(v: number): string { + if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`; + return v.toLocaleString(); +} + +export default function ArtistCard({ + artist, + onVote, + className, +}: ArtistCardProps) { + const inTop12 = artist.rank <= 12; + + return ( +
+ + {/* 立绘区 */} +
+ + + {/* 编号徽章(左上) */} +
+ No.{artist.no} +
+ + {/* 排名徽章(右上) */} +
+ {artist.rank} +
+ + {/* 顶部渐变蒙层(让编号更清晰) */} +
+
+ + {/* 信息区 */} +
+
+ {artist.name}{" "} + · {artist.enName} +
+
+ {artist.slogan} +
+
+ ❤ {formatVotes(artist.votes)} 票 +
+
+ + + {/* 投票按钮 */} +
+ +
+
+ ); +} diff --git a/src/components/cards/ArtistPortrait.tsx b/src/components/cards/ArtistPortrait.tsx new file mode 100644 index 0000000..108f833 --- /dev/null +++ b/src/components/cards/ArtistPortrait.tsx @@ -0,0 +1,76 @@ +import Image from "next/image"; +import type { Artist } from "@/types/artist"; +import { cn } from "@/lib/cn"; + +interface ArtistPortraitProps { + artist: Artist; + className?: string; + /** 圆角覆盖 */ + rounded?: string; +} + +/** + * 艺人立绘容器。 + * 有真实图时显示 Image;否则渲染基于 themeColor 的渐变占位(带首字母)。 + */ +export default function ArtistPortrait({ + artist, + className, + rounded = "rounded-lg", +}: ArtistPortraitProps) { + const initial = + artist.enName?.charAt(0).toUpperCase() || + artist.name?.charAt(0) || + "?"; + + if (artist.portrait) { + return ( +
+ {`${artist.name} +
+ ); + } + + return ( +
+ {/* 装饰光晕 */} +
+ {/* 装饰星标 */} + + + {/* 首字母 */} + + {initial} + +
+ ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..18552e2 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,90 @@ +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type Variant = "primary" | "outline" | "ghost" | "danger"; +type Size = "sm" | "md" | "lg" | "icon"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + pulse?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + loading?: boolean; +} + +const BASE = + "relative inline-flex items-center justify-center gap-2 rounded font-display uppercase transition-all overflow-hidden cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:pointer-events-none"; + +const VARIANTS: Record = { + primary: + "bg-grad-purple text-white shadow-purple-glow hover:brightness-110 hover:-translate-y-0.5 active:brightness-95 active:translate-y-0", + outline: + "bg-transparent text-purple-300 border border-[var(--border-purple)] shadow-[0_0_12px_rgba(139,92,246,0.12)] hover:bg-purple-500/10 hover:shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:text-purple-200", + ghost: + "bg-white/5 text-white/70 border border-white/14 hover:bg-white/10 hover:text-white", + danger: + "bg-pink-500 text-white shadow-[0_0_20px_rgba(236,72,153,0.4)] hover:brightness-110", +}; + +const SIZES: Record = { + sm: "h-8 px-3.5 text-[10px] tracking-[1.5px]", + md: "h-11 px-6 text-xs tracking-[2.5px]", + lg: "h-14 px-10 text-sm tracking-[2.5px]", + icon: "h-11 w-11 p-0 text-base tracking-normal", +}; + +const Button = forwardRef(function Button( + { + className, + variant = "primary", + size = "md", + pulse = false, + leftIcon, + rightIcon, + loading = false, + disabled, + children, + ...props + }, + ref, +) { + return ( + + ); +}); + +export default Button; diff --git a/src/components/ui/Countdown.tsx b/src/components/ui/Countdown.tsx new file mode 100644 index 0000000..18d8dd4 --- /dev/null +++ b/src/components/ui/Countdown.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn } from "@/lib/cn"; + +interface CountdownProps { + /** 截止时间 */ + endTime: Date | string | number; + /** 紧凑模式(横向小尺寸) */ + compact?: boolean; + className?: string; +} + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; + finished: boolean; +} + +function computeTimeLeft(end: number): TimeLeft { + const diff = Math.max(0, end - Date.now()); + const days = Math.floor(diff / 86_400_000); + const hours = Math.floor((diff % 86_400_000) / 3_600_000); + const minutes = Math.floor((diff % 3_600_000) / 60_000); + const seconds = Math.floor((diff % 60_000) / 1_000); + return { days, hours, minutes, seconds, finished: diff <= 0 }; +} + +const UNIT_LABELS = [ + ["days", "Days"], + ["hours", "Hours"], + ["minutes", "Min"], + ["seconds", "Sec"], +] as const; + +export default function Countdown({ + endTime, + compact = false, + className, +}: CountdownProps) { + const end = new Date(endTime).getTime(); + const [time, setTime] = useState(null); + + // 客户端首次渲染后再启动 tick,避免 SSR 与客户端时间不一致 + useEffect(() => { + setTime(computeTimeLeft(end)); + const id = setInterval(() => setTime(computeTimeLeft(end)), 1000); + return () => clearInterval(id); + }, [end]); + + if (!time) { + // 占位高度避免布局抖动 + return ( +
+ ); + } + + if (time.finished) { + return ( +
+ Activity Ended +
+ ); + } + + const values = [time.days, time.hours, time.minutes, time.seconds]; + + if (compact) { + return ( +
+ + ⏱ + + + {time.days}d {String(time.hours).padStart(2, "0")}: + {String(time.minutes).padStart(2, "0")}: + {String(time.seconds).padStart(2, "0")} + +
+ ); + } + + return ( +
+ {values.map((v, i) => ( +
+
+
+ {String(v).padStart(2, "0")} +
+
+ {UNIT_LABELS[i]![1]} +
+
+ {i < 3 && ( + : + )} +
+ ))} +
+ ); +} diff --git a/src/lib/cn.ts b/src/lib/cn.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts new file mode 100644 index 0000000..2b54ff5 --- /dev/null +++ b/src/lib/mock-data.ts @@ -0,0 +1,135 @@ +import type { Artist, ArtistTag } from "@/types/artist"; + +const STAGE_NAMES: Array<[string, string, string]> = [ + ["艺奈", "AURORA", "破晓极光"], + ["路米", "LUMI", "暖光治愈"], + ["星澪", "NEBULA", "星云吟唱"], + ["凯", "KAI", "海岸少年"], + ["回音", "ECHO", "声波女王"], + ["薇尔", "VEIL", "薄雾低语"], + ["艾莉雅", "ARIA", "咏叹之声"], + ["怜", "REN", "莲华少女"], + ["米拉", "MIRA", "镜面舞者"], + ["诺娃", "NOVA", "超新星"], + ["纪罗", "KIRO", "Rap 制造机"], + ["瑞", "ZUI", "醉月夜"], + ["阳", "SOL", "阳光少年"], + ["凛", "LIN", "学院偶像"], + ["律", "LYRA", "竖琴公主"], + ["昕", "DAWN", "晨曦少女"], + ["天", "SKY", "天空之翼"], + ["语", "ARIE", "诗与远方"], + ["翼", "WING", "飞翔之翼"], + ["铃", "CHIME", "风铃声"], + ["夜", "NYX", "暗夜女神"], + ["晴", "SUNNY", "晴空万里"], + ["月", "LUNA", "月光女神"], + ["岚", "STORM", "暴风之子"], + ["雷", "BOLT", "雷霆速度"], + ["焰", "FLARE", "火焰之心"], + ["雪", "FROST", "霜花少女"], + ["林", "LEAF", "森林精灵"], + ["渊", "ABYSS", "深渊之声"], + ["瑶", "JADE", "翡翠少女"], + ["晨", "AURIA", "金色晨光"], + ["岩", "ROCK", "硬核摇滚"], + ["翔", "SOAR", "翱翔天际"], + ["茉", "MOLLY", "茉莉芬芳"], + ["梓", "AZUR", "蓝调诗人"], +]; + +const TAG_POOL: ArtistTag[][] = [ + ["vocal", "visual"], + ["dance", "all-rounder"], + ["rap", "leader"], + ["all-rounder"], + ["vocal", "leader"], + ["dance", "visual"], + ["vocal"], + ["rap", "all-rounder"], + ["dance"], + ["visual", "all-rounder"], + ["rap"], + ["vocal", "dance"], +]; + +const THEME_COLORS = [ + "#8b5cf6", + "#ec4899", + "#06b6d4", + "#f59e0b", + "#10b981", + "#ef4444", + "#a78bfa", + "#f472b6", + "#38bdf8", + "#fbbf24", + "#34d399", + "#fb7185", +]; + +/** 生成确定性 35 位艺人 mock 数据 */ +function buildArtists(): Artist[] { + return STAGE_NAMES.map(([name, enName, slogan], idx) => { + const no = String(idx + 1).padStart(3, "0"); + const rank = idx + 1; + // 票数采用反比例衰减:第一名最多,往后递减 + const votes = Math.round(125000 / Math.sqrt(rank) + Math.random() * 3000); + + return { + id: no, + no, + name, + enName, + slogan, + bio: `来自虚拟星域的偶像候选人 ${enName},从小热爱音乐与舞蹈。性格${ + rank % 2 === 0 ? "温柔" : "活泼" + },擅长${ + idx % 3 === 0 ? "抒情曲" : idx % 3 === 1 ? "舞台表演" : "Rap 创作" + }。曾获得多项虚拟偶像新人奖项,代表作品深受粉丝喜爱。立志成为 Top12 出道阵容的一员,用音乐传递梦想与力量。`, + portrait: "", + avatar: "", + gallery: ["", "", "", "", ""], + videoUrl: undefined, + videoPoster: "", + tags: TAG_POOL[idx % TAG_POOL.length]!, + birthday: `${String(((idx * 7) % 12) + 1).padStart(2, "0")}-${String( + ((idx * 13) % 28) + 1 + ).padStart(2, "0")}`, + height: 158 + (idx % 12), + cv: idx < 12 ? `CV 配音 #${idx + 1}` : undefined, + themeColor: THEME_COLORS[idx % THEME_COLORS.length]!, + votes, + rank, + }; + }); +} + +export const ARTISTS: Artist[] = buildArtists(); + +export const TOP_12 = ARTISTS.slice(0, 12); +export const CANDIDATES = ARTISTS.slice(12); + +/** 按当前排序方式获取艺人列表 */ +export type SortKey = "votes" | "no" | "recent"; +export function sortArtists(list: Artist[], key: SortKey = "votes"): Artist[] { + const sorted = [...list]; + if (key === "votes") sorted.sort((a, b) => b.votes - a.votes); + else if (key === "no") sorted.sort((a, b) => a.no.localeCompare(b.no)); + return sorted; +} + +/** 按 ID 获取艺人 */ +export function getArtist(id: string): Artist | undefined { + return ARTISTS.find((a) => a.id === id); +} + +/** 活动结束时间(mock:当前日期 + 12 天) */ +export function getActivityEndTime(): Date { + const end = new Date(); + end.setDate(end.getDate() + 12); + end.setHours(end.getHours() + 3); + end.setMinutes(end.getMinutes() + 24); + end.setSeconds(end.getSeconds() + 18); + return end; +} diff --git a/src/types/artist.ts b/src/types/artist.ts new file mode 100644 index 0000000..48e11a7 --- /dev/null +++ b/src/types/artist.ts @@ -0,0 +1,65 @@ +export type ArtistTag = + | "vocal" + | "dance" + | "rap" + | "all-rounder" + | "visual" + | "leader"; + +export interface Artist { + /** 唯一 ID(如 001 ~ 035) */ + id: string; + /** 编号字符串(如 "001") */ + no: string; + /** 中文名 */ + name: string; + /** 英文名 / 艺名 */ + enName: string; + /** Slogan 短宣传语 */ + slogan: string; + /** 长简介 (200-500 字) */ + bio: string; + /** 立绘主图 URL */ + portrait: string; + /** 圆形头像 URL */ + avatar: string; + /** 表演视频 URL (15s) */ + videoUrl?: string; + /** 视频封面图 */ + videoPoster?: string; + /** 表演图片轮播 */ + gallery: string[]; + /** 标签 */ + tags: ArtistTag[]; + /** 生日 MM-DD */ + birthday: string; + /** 身高 cm */ + height: number; + /** CV / 声优 */ + cv?: string; + /** 应援色 hex */ + themeColor: string; + /** 当前票数 */ + votes: number; + /** 当前排名 (1-35) */ + rank: number; +} + +export const TAG_LABEL: Record = { + vocal: "声乐担当", + dance: "舞蹈担当", + rap: "Rap 担当", + "all-rounder": "全能型", + visual: "颜值担当", + leader: "队长担当", +}; + +export type RankCategory = "gold" | "silver" | "bronze" | "top12" | "candidate"; + +export function getRankCategory(rank: number): RankCategory { + if (rank === 1) return "gold"; + if (rank === 2) return "silver"; + if (rank === 3) return "bronze"; + if (rank <= 12) return "top12"; + return "candidate"; +}