fix(ux): center modals with overlay; live vote with toast; deterministic mock data; cascade layer fix

This commit is contained in:
iye 2026-05-12 14:09:31 +08:00
parent 7949f9bcd1
commit 9fe9fa914f
20 changed files with 818 additions and 173 deletions

View File

@ -1,7 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// 关闭左下角的开发指示器dev overlay 角标)
devIndicators: false,
};
export default nextConfig;

View File

@ -25,8 +25,10 @@
"prisma": "^6.19.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hot-toast": "^2.6.0",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

52
pnpm-lock.yaml generated
View File

@ -41,12 +41,18 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
zod:
specifier: ^4.4.3
version: 4.4.3
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@19.2.14)(react@19.2.4)
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@ -1565,6 +1571,11 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
goober@2.1.18:
resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==}
peerDependencies:
csstype: ^3.0.10
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@ -2173,6 +2184,13 @@ packages:
peerDependencies:
react: ^19.2.4
react-hot-toast@2.6.0:
resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -2509,6 +2527,24 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -4043,6 +4079,10 @@ snapshots:
define-properties: 1.2.1
gopd: 1.2.0
goober@2.1.18(csstype@3.2.3):
dependencies:
csstype: 3.2.3
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@ -4607,6 +4647,13 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-hot-toast@2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
csstype: 3.2.3
goober: 2.1.18(csstype@3.2.3)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-is@16.13.1: {}
react@19.2.4: {}
@ -5071,3 +5118,8 @@ snapshots:
zod: 4.4.3
zod@4.4.3: {}
zustand@5.0.13(@types/react@19.2.14)(react@19.2.4):
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.4

View File

@ -148,10 +148,14 @@ body::after {
z-index: 0;
}
/* 页面内容须高于装饰层 */
body > * {
position: relative;
z-index: 1;
/* 页面内容须高于装饰层
* 必须放入 @layer base否则会覆盖 Tailwind .fixed 等工具类
* 导致 createPortal 的弹窗失去 position:fixed 被排到页面底部 */
@layer base {
body > * {
position: relative;
z-index: 1;
}
}
/* ── 选中文字 ── */

View File

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Megrim, Audiowide, Cinzel, Inter } from "next/font/google";
import Navigation from "@/components/Navigation";
import Footer from "@/components/Footer";
import Providers from "@/components/Providers";
import "./globals.css";
const megrim = Megrim({
@ -54,9 +55,11 @@ export default function RootLayout({
className={`${megrim.variable} ${audiowide.variable} ${cinzel.variable} ${inter.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Navigation />
<main className="flex-1">{children}</main>
<Footer />
<Providers>
<Navigation />
<main className="flex-1">{children}</main>
<Footer />
</Providers>
</body>
</html>
);

146
src/app/me/MeContent.tsx Normal file
View File

@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { Gift, Star, LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
import toast from "react-hot-toast";
import UserHeader from "@/components/me/UserHeader";
import QuotaCard from "@/components/me/QuotaCard";
import StatsGrid from "@/components/me/StatsGrid";
import SignInCalendar from "@/components/me/SignInCalendar";
import MyFanSupport from "@/components/me/MyFanSupport";
import { MOCK_USER, getFanSupports, type MockUser } from "@/lib/mock-user";
import { useVoteStore } from "@/lib/store";
interface MeContentProps {
session: {
id: string;
nickname: string;
};
}
export default function MeContent({ session }: MeContentProps) {
const remaining = useVoteStore((s) => s.remainingVotes);
const used = useVoteStore((s) => s.usedVotes);
const dailyQuota = useVoteStore((s) => s.dailyQuota);
const storeArtists = useVoteStore((s) => s.artists);
// 本地签到状态(数据库就绪后由 /api/me/signin 提供)
const [signedInToday, setSignedInToday] = useState(MOCK_USER.todaySignedIn);
const [weeklySignIn, setWeeklySignIn] = useState(MOCK_USER.weeklySignIn);
const user: MockUser = {
...MOCK_USER,
id: session.id,
nickname: session.nickname,
remainingVotes: remaining,
usedVotes: used,
dailyQuota,
todaySignedIn: signedInToday,
weeklySignIn,
totalVotes: MOCK_USER.totalVotes + used,
};
// 用 store 里最新的艺人排名重算 "我的应援" 当前排名
const supports = getFanSupports().map((s) => {
const fresh = storeArtists.find((a) => a.id === s.artist.id);
return fresh ? { ...s, artist: fresh } : s;
});
const handleInvite = async () => {
const url =
typeof window !== "undefined"
? `${window.location.origin}?invite=${session.id}`
: "";
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({
title: "CYBER STAR · 一起为偶像应援",
text: "邀请你加入虚拟偶像 Top12 出道企划,双方各得 +5 票!",
url,
});
return;
} catch {
return;
}
}
try {
await navigator.clipboard.writeText(url);
toast.success("邀请链接已复制 · 朋友注册后双方各 +5 票");
} catch {
toast.error("复制失败,请手动复制地址");
}
};
const handleSignIn = () => {
if (signedInToday) {
toast("今日已签到", { icon: "✓" });
return;
}
// 在 weeklySignIn 数组里找到今天的位置(第一个 false
const idx = weeklySignIn.findIndex((v) => !v);
if (idx === -1) {
toast("本周已全部签到");
return;
}
const next = [...weeklySignIn];
next[idx] = true;
setWeeklySignIn(next);
setSignedInToday(true);
toast.success("签到成功 · 获得 +3 票");
};
const handleLogout = () => {
toast("正在退出登录…", { icon: "👋" });
signOut({ callbackUrl: "/" });
};
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader user={user} />
<QuotaCard
remaining={user.remainingVotes}
daily={user.dailyQuota}
onInvite={handleInvite}
/>
<StatsGrid user={user} />
<section>
<div className="flex items-center gap-2 mb-3">
<Gift size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<SignInCalendar
weekly={user.weeklySignIn}
todaySigned={user.todaySignedIn}
onSignIn={handleSignIn}
/>
</section>
<section>
<div className="flex items-center gap-2 mb-3">
<Star size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<MyFanSupport supports={supports} />
</section>
<div className="pt-4 border-t border-white/[0.06] flex justify-center">
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-2 px-4 h-10 rounded-full bg-white/[0.04] border border-white/10 text-white/55 hover:text-pink-400 hover:border-pink-500/40 transition-colors font-display text-xs tracking-widest uppercase"
>
<LogOut size={13} />
退
</button>
</div>
</div>
);
}

View File

@ -1,64 +1,27 @@
"use client";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { auth } from "@/lib/auth";
import MeContent from "./MeContent";
import { Gift, Star } from "lucide-react";
import UserHeader from "@/components/me/UserHeader";
import QuotaCard from "@/components/me/QuotaCard";
import StatsGrid from "@/components/me/StatsGrid";
import SignInCalendar from "@/components/me/SignInCalendar";
import MyFanSupport from "@/components/me/MyFanSupport";
import { MOCK_USER, getFanSupports } from "@/lib/mock-user";
export const metadata: Metadata = {
title: "个人中心 · CYBER STAR",
};
export default function MePage() {
const user = MOCK_USER;
const supports = getFanSupports();
export default async function MePage() {
const session = await auth();
const handleInvite = () => {
// TODO: 邀请好友逻辑(生成专属海报 / 复制链接)
console.log("Invite friends");
};
const handleSignIn = () => {
// TODO: 调用签到 API
console.log("Sign in");
};
// 未登录 → 跳登录页(登录后回到 /me
if (!session?.user) {
redirect("/login?callbackUrl=/me");
}
const sessionUser = session.user as { id?: string; name?: string | null };
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader user={user} />
<QuotaCard
remaining={user.remainingVotes}
daily={user.dailyQuota}
onInvite={handleInvite}
/>
<StatsGrid user={user} />
{/* 签到 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Gift size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<SignInCalendar
weekly={user.weeklySignIn}
todaySigned={user.todaySignedIn}
onSignIn={handleSignIn}
/>
</section>
{/* 我的应援 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Star size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<MyFanSupport supports={supports} />
</section>
</div>
<MeContent
session={{
id: sessionUser.id ?? "0",
nickname: sessionUser.name ?? "粉丝",
}}
/>
);
}

View File

@ -12,13 +12,17 @@ import ArtistFilters, {
} from "@/components/ArtistFilters";
import VoteModal from "@/components/VoteModal";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { ARTISTS, sortArtists, getActivityEndTime } from "@/lib/mock-data";
import { getActivityEndTime, sortArtists } from "@/lib/mock-data";
import type { SortKey } from "@/lib/mock-data";
import type { Artist } from "@/types/artist";
import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { cn } from "@/lib/cn";
export default function Home() {
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
const artists = useVoteStore((s) => s.artists);
const { target, openVote, closeVote, confirmVote } = useVoteAction();
const [sortKey, setSortKey] = useState<SortKey>("votes");
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
const [search, setSearch] = useState("");
@ -27,7 +31,7 @@ export default function Home() {
const endTime = useMemo(() => getActivityEndTime(), []);
const visibleArtists = useMemo(() => {
let list = [...ARTISTS];
let list = [...artists];
if (tagFilter !== "all") {
list = list.filter((a) => a.tags.includes(tagFilter));
}
@ -41,14 +45,7 @@ export default function Home() {
);
}
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);
};
}, [artists, sortKey, tagFilter, search]);
return (
<>
@ -72,7 +69,7 @@ export default function Home() {
<ChevronRight size={12} />
</Link>
</div>
<Top12Bar artists={ARTISTS} />
<Top12Bar artists={artists} />
</section>
{/* 候选人阵容 */}
@ -106,35 +103,29 @@ export default function Home() {
onViewChange={setView}
/>
{/* Artist list */}
{visibleArtists.length === 0 ? (
<EmptyState />
) : view === "grid" ? (
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
{visibleArtists.map((a) => (
<ArtistCard key={a.id} artist={a} onVote={setVoteTarget} />
<ArtistCard key={a.id} artist={a} onVote={openVote} />
))}
</div>
) : (
<div className="mt-5 space-y-2">
{visibleArtists.map((a) => (
<ArtistListRow key={a.id} artist={a} onVote={setVoteTarget} />
<ArtistListRow key={a.id} artist={a} onVote={openVote} />
))}
</div>
)}
</section>
{/* 投票弹窗 */}
<VoteModal
artist={voteTarget}
onClose={() => setVoteTarget(null)}
onConfirm={handleVote}
/>
<VoteModal artist={target} onClose={closeVote} onConfirm={confirmVote} />
</>
);
}
/** 列表视图行 */
function ArtistListRow({
artist,
onVote,

View File

@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Sparkles } from "lucide-react";
import Top3Podium from "@/components/ranking/Top3Podium";
import RankingRow from "@/components/ranking/RankingRow";
@ -8,27 +8,29 @@ import DebutLineDivider from "@/components/ranking/DebutLineDivider";
import VoteModal from "@/components/VoteModal";
import Countdown from "@/components/ui/Countdown";
import LiveBadge from "@/components/LiveBadge";
import { ARTISTS, getActivityEndTime, sortArtists } from "@/lib/mock-data";
import { getActivityEndTime, sortArtists } from "@/lib/mock-data";
import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { useRanking } from "@/hooks/useRanking";
import type { Artist } from "@/types/artist";
export default function RankingPage() {
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
const storeArtists = useVoteStore((s) => s.artists);
const { target, openVote, closeVote, confirmVote } = useVoteAction();
// 实时排名API 可用时生效;失败则继续用 mock
// 实时排名API 可用时生效;失败则继续用 store 本地数据
const live = useRanking({ pollInterval: 30_000 });
const sorted = useMemo<Artist[]>(() => {
if (live.data?.list && live.data.list.length > 0) {
// 用 API 数据合并 mockAPI 只返回基础字段,详情仍从 mock 取)
return live.data.list.map((row) => {
const base = ARTISTS.find((a) => a.id === row.id);
const base = storeArtists.find((a) => a.id === row.id);
if (!base) return row as unknown as Artist;
return { ...base, votes: row.voteCount, rank: row.rank };
});
}
return sortArtists(ARTISTS, "votes");
}, [live.data]);
return sortArtists(storeArtists, "votes");
}, [storeArtists, live.data]);
const endTime = useMemo(() => getActivityEndTime(), []);
@ -36,15 +38,8 @@ export default function RankingPage() {
const top4to12 = sorted.slice(3, 12);
const candidates = sorted.slice(12);
// 计算 #13 与第 12 名的差距,用于"救援投票"
const debutCutoff = sorted[11]?.votes ?? 0;
const handleVote = async (a: Artist, count: number) => {
await new Promise((r) => setTimeout(r, 400));
console.log(`Vote: ${a.name} × ${count}`);
setVoteTarget(null);
};
return (
<>
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
@ -76,7 +71,6 @@ export default function RankingPage() {
</h2>
</div>
{/* 表头 */}
<div className="hidden sm:grid grid-cols-[64px_64px_1fr_100px_140px_110px] gap-4 px-3 py-2 text-[10px] tracking-widest uppercase text-white/40 font-label">
<span className="text-center"></span>
<span></span>
@ -86,7 +80,6 @@ export default function RankingPage() {
<span className="text-center"></span>
</div>
{/* Top4-12 行 */}
<div className="space-y-2">
{top4to12.map((a, idx) => {
const prev = idx === 0 ? top3[2] : top4to12[idx - 1];
@ -96,21 +89,19 @@ export default function RankingPage() {
key={a.id}
artist={a}
gapAbove={gap}
onVote={setVoteTarget}
onVote={openVote}
/>
);
})}
</div>
{/* 出道线 */}
<DebutLineDivider />
{/* 候补区 */}
<div className="space-y-2">
{candidates.map((a, idx) => {
const prev = idx === 0 ? top4to12[top4to12.length - 1] : candidates[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
const isRescue = idx === 0; // 第 13 位
const isRescue = idx === 0;
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
return (
<RankingRow
@ -119,23 +110,18 @@ export default function RankingPage() {
gapAbove={gap}
gapToDebut={gapToDebut}
isRescue={isRescue}
onVote={setVoteTarget}
onVote={openVote}
/>
);
})}
</div>
{/* 底部提示 */}
<p className="text-xs text-white/35 text-center mt-10">
30 ·
</p>
</div>
<VoteModal
artist={voteTarget}
onClose={() => setVoteTarget(null)}
onConfirm={handleVote}
/>
<VoteModal artist={target} onClose={closeVote} onConfirm={confirmVote} />
</>
);
}

View File

@ -7,7 +7,6 @@ import { cn } from "@/lib/cn";
const NAV_ITEMS = [
{ label: "HOME", href: "/" },
{ label: "RANKING", href: "/ranking" },
{ label: "ME", href: "/me" },
] as const;
interface NavLinksProps {

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { auth } from "@/lib/auth";
import Logo from "./Logo";
import NavLinks from "./NavLinks";
import SearchTrigger from "./SearchTrigger";
export default async function Navigation() {
const session = await auth();
@ -17,25 +18,7 @@ export default async function Navigation() {
{/* 右侧 */}
<div className="ml-auto flex items-center gap-3">
<button
type="button"
aria-label="搜索"
className="w-9 h-9 rounded-md flex items-center justify-center text-white/65 hover:text-white hover:bg-white/[0.06] transition-colors"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
<SearchTrigger />
{user ? (
<Link

View File

@ -0,0 +1,47 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { Toaster } from "react-hot-toast";
/**
* Provider
* - SessionProvider: client useSession()
* - Toaster: 全站 toast
*/
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
<Toaster
position="top-center"
toastOptions={{
duration: 2800,
style: {
background: "rgba(34, 29, 74, 0.95)",
backdropFilter: "blur(16px)",
color: "#fff",
border: "1px solid rgba(139, 92, 246, 0.4)",
boxShadow:
"0 12px 32px rgba(0,0,0,0.5), 0 0 24px rgba(139,92,246,0.25)",
fontSize: "13px",
letterSpacing: "0.5px",
padding: "10px 16px",
borderRadius: "12px",
},
success: {
iconTheme: {
primary: "#a78bfa",
secondary: "#fff",
},
},
error: {
iconTheme: {
primary: "#f472b6",
secondary: "#fff",
},
},
}}
/>
</SessionProvider>
);
}

View File

@ -0,0 +1,276 @@
"use client";
import { useEffect, useState, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ARTISTS } from "@/lib/mock-data";
import { TAG_LABEL, type Artist } from "@/types/artist";
import ArtistPortrait from "./cards/ArtistPortrait";
import { cn } from "@/lib/cn";
interface SearchModalProps {
open: boolean;
onClose: () => void;
}
export default function SearchModal({ open, onClose }: SearchModalProps) {
const router = useRouter();
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [mounted, setMounted] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => setMounted(true), []);
// 打开时重置 + 自动聚焦 + 锁滚
useEffect(() => {
if (!open) return;
setQuery("");
setActiveIndex(0);
const t = setTimeout(() => inputRef.current?.focus(), 80);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
clearTimeout(t);
document.body.style.overflow = prev;
};
}, [open]);
// 过滤艺人:名字 / 编号 / slogan / 标签
const results = useMemo<Artist[]>(() => {
const q = query.trim().toLowerCase();
if (!q) return ARTISTS.slice(0, 12); // 默认显示前 12 名
return ARTISTS.filter((a) => {
const tagText = a.tags.map((t) => TAG_LABEL[t]).join("");
return (
a.name.toLowerCase().includes(q) ||
a.enName.toLowerCase().includes(q) ||
a.no.includes(q) ||
a.slogan.toLowerCase().includes(q) ||
tagText.includes(q)
);
}).slice(0, 20);
}, [query]);
// 键盘导航
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const target = results[activeIndex];
if (target) {
router.push(`/artist/${target.id}`);
onClose();
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose, results, activeIndex, router]);
// 选中项变化时滚动到可视范围
useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.querySelector(
`[data-index="${activeIndex}"]`,
) as HTMLElement | null;
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
// query 变化时重置高亮
useEffect(() => {
setActiveIndex(0);
}, [query]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && (
<motion.div
key="search-modal"
className="fixed inset-0 z-[100] flex items-center justify-center px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
{/* 黑色透明遮罩(点击关闭) · 视觉规范rgba(0,0,0,0.75) + blur */}
<button
type="button"
aria-label="关闭搜索"
onClick={onClose}
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
/>
{/* 弹窗主体:居中固定在遮罩层上 */}
<motion.div
role="dialog"
aria-label="搜索艺人"
className="relative w-full max-w-xl bg-elevated/95 backdrop-blur-xl border border-white/14 rounded-2xl shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_40px_rgba(139,92,246,0.18)] overflow-hidden"
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
>
{/* 顶部紫色光条 */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-grad-purple" />
{/* 输入框 */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/[0.06]">
<Search size={18} className="text-purple-300/80 flex-shrink-0" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索艺人 · 名称 / 编号 / 标签…"
className="flex-1 bg-transparent border-none outline-none text-white placeholder-white/35 text-base"
/>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="text-white/55 hover:text-white"
>
<X size={18} />
</button>
</div>
{/* 结果列表 */}
<div
ref={listRef}
className="max-h-[55vh] sm:max-h-[400px] overflow-y-auto py-2"
>
{results.length === 0 ? (
<EmptyState query={query} />
) : (
<>
{!query && (
<div className="px-5 pt-1 pb-2">
<p className="font-label text-[10px] tracking-widest uppercase text-purple-300/70">
· TOP 12
</p>
</div>
)}
{results.map((a, i) => (
<ResultRow
key={a.id}
artist={a}
active={i === activeIndex}
index={i}
onHover={() => setActiveIndex(i)}
onClick={onClose}
/>
))}
</>
)}
</div>
{/* 底部结果计数 */}
<div className="hidden sm:flex items-center justify-end px-5 py-2 border-t border-white/[0.06] text-[11px] text-white/40 bg-deep/40">
<span className="font-mono"> {results.length} </span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
function ResultRow({
artist,
active,
index,
onHover,
onClick,
}: {
artist: Artist;
active: boolean;
index: number;
onHover: () => void;
onClick: () => void;
}) {
const inTop12 = artist.rank <= 12;
return (
<Link
data-index={index}
href={`/artist/${artist.id}`}
onClick={onClick}
onMouseEnter={onHover}
className={cn(
"flex items-center gap-3 px-5 py-2.5 transition-colors",
active ? "bg-purple-500/15" : "hover:bg-white/[0.04]",
)}
>
<div className="w-10 h-10 rounded-lg overflow-hidden border border-white/15 flex-shrink-0">
<ArtistPortrait
artist={artist}
rounded="rounded-none"
className="w-full h-full"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5 text-sm">
<span className="font-display text-[10px] text-purple-300/80 tabular-nums">
No.{artist.no}
</span>
<span className="font-semibold text-white truncate">
{artist.name}
</span>
<span className="text-white/55 text-xs truncate">
· {artist.enName}
</span>
</div>
<div className="text-[11px] text-white/45 truncate">
{artist.slogan}
</div>
</div>
<div className="flex-shrink-0 text-right">
<div
className={cn(
"font-display text-xs tabular-nums",
inTop12 ? "text-purple-300" : "text-white/40",
)}
>
#{artist.rank}
</div>
<div className="text-[10px] text-white/35 tabular-nums">
{(artist.votes / 10000).toFixed(1)}w
</div>
</div>
</Link>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="py-12 px-5 text-center">
<div className="w-12 h-12 rounded-full bg-white/[0.04] border border-white/10 mx-auto mb-3 flex items-center justify-center text-white/35">
<Search size={18} />
</div>
<p className="text-sm text-white/65">
<b className="text-purple-300">{query}</b>
</p>
<p className="text-[11px] text-white/40 mt-1">
/ / /
</p>
</div>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import { Search } from "lucide-react";
import SearchModal from "./SearchModal";
export default function SearchTrigger() {
const [open, setOpen] = useState(false);
// 隐藏快捷键 Cmd+K / Ctrl+K不在 UI 暴露,但保留方便用)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="搜索艺人"
title="搜索艺人"
className="w-9 h-9 rounded-md flex items-center justify-center text-white/65 hover:text-white hover:bg-white/[0.06] transition-colors"
>
<Search size={16} />
</button>
<SearchModal open={open} onClose={() => setOpen(false)} />
</>
);
}

View File

@ -9,13 +9,11 @@ import { cn } from "@/lib/cn";
import Button from "./ui/Button";
import ArtistPortrait from "./cards/ArtistPortrait";
import { useVoteStore } from "@/lib/store";
interface VoteModalProps {
/** 当前要投票的艺人,传 null 关闭弹窗 */
artist: Artist | null;
/** 今日剩余票数 */
remainingVotes?: number;
/** 今日已用票数 */
usedVotes?: number;
/** 单艺人每日上限 */
perArtistLimit?: number;
/** 关闭弹窗 */
@ -28,12 +26,12 @@ const VOTE_OPTIONS: Array<number | "ALL"> = [1, 3, 5, "ALL"];
export default function VoteModal({
artist,
remainingVotes = 12,
usedVotes = 0,
perArtistLimit = 3,
onClose,
onConfirm,
}: VoteModalProps) {
const remainingVotes = useVoteStore((s) => s.remainingVotes);
const usedVotes = useVoteStore((s) => s.usedVotes);
const open = artist != null;
const [selected, setSelected] = useState<number | "ALL">(3);
const [loading, setLoading] = useState(false);

View File

@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronLeft, Heart, Share2 } from "lucide-react";
import toast from "react-hot-toast";
import type { Artist } from "@/types/artist";
import { TAG_LABEL } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
@ -12,6 +12,8 @@ import RankCard from "./RankCard";
import PerformanceVideo from "./PerformanceVideo";
import PerformanceGallery from "./PerformanceGallery";
import FloatingVoteButton from "@/components/FloatingVoteButton";
import { useVoteStore, selectArtist } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
interface ArtistDetailContentProps {
artist: Artist;
@ -19,20 +21,51 @@ interface ArtistDetailContentProps {
}
export default function ArtistDetailContent({
artist,
allArtists,
artist: initialArtist,
allArtists: initialAll,
}: ArtistDetailContentProps) {
const [voteOpen, setVoteOpen] = useState(false);
// 用 store 数据覆盖(这样投票后票数能马上变)
const storeArtist = useVoteStore(selectArtist(initialArtist.id));
const storeAll = useVoteStore((s) => s.artists);
const handleVote = async (a: Artist, count: number) => {
await new Promise((r) => setTimeout(r, 400));
console.log(`Vote: ${a.name} × ${count}`);
setVoteOpen(false);
const artist = storeArtist ?? initialArtist;
const allArtists = storeAll.length ? storeAll : initialAll;
const { target, openVote, closeVote, confirmVote } = useVoteAction();
const handleShare = async () => {
const url =
typeof window !== "undefined"
? `${window.location.origin}/artist/${artist.id}`
: `/artist/${artist.id}`;
const shareData = {
title: `${artist.name} · CYBER STAR`,
text: `${artist.name}${artist.enName})打 Call${artist.slogan}`,
url,
};
// 优先用 Web Share API移动端 / Safari 支持)
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share(shareData);
return;
} catch {
// 用户取消分享:不报错
return;
}
}
// 兜底:复制到剪贴板
try {
await navigator.clipboard.writeText(url);
toast.success("链接已复制,去粘贴给朋友吧~");
} catch {
toast.error("复制失败,请手动复制地址栏");
}
};
return (
<>
{/* 简化面包屑 */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4">
<div className="h-12 flex items-center gap-3 text-sm">
<Link
@ -50,6 +83,7 @@ export default function ArtistDetailContent({
<span className="text-white/85">{artist.name}</span>
<button
type="button"
onClick={handleShare}
className="ml-auto inline-flex items-center gap-1.5 text-purple-300 hover:text-purple-200 text-xs"
>
<Share2 size={14} />
@ -58,7 +92,6 @@ export default function ArtistDetailContent({
</div>
</div>
{/* Hero 区 */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<div
className="rounded-2xl border border-white/[0.06] overflow-hidden p-5 sm:p-8 grid gap-6 lg:grid-cols-[340px_1fr] lg:gap-8"
@ -67,14 +100,12 @@ export default function ArtistDetailContent({
"linear-gradient(135deg, rgba(139,92,246,0.06) 0%, rgba(13,10,36,0.6) 100%)",
}}
>
{/* 立绘 */}
<div className="relative">
<ArtistPortrait
artist={artist}
rounded="rounded-xl"
className="w-full aspect-[4/5] shadow-card"
/>
{/* 应援色装饰条 */}
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-black/35 border border-white/10">
<span
className="w-4 h-4 rounded-full ring-2 ring-white/20"
@ -89,7 +120,6 @@ export default function ArtistDetailContent({
</div>
</div>
{/* 信息区 */}
<div className="flex flex-col gap-4">
<div>
<div className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-2">
@ -103,7 +133,6 @@ export default function ArtistDetailContent({
</p>
</div>
{/* 标签 */}
<div className="flex gap-2 flex-wrap">
{artist.tags.map((t) => (
<span
@ -115,17 +144,14 @@ export default function ArtistDetailContent({
))}
</div>
{/* 元信息 */}
<div className="grid grid-cols-3 gap-2">
<MetaCell label="生日" value={artist.birthday} />
<MetaCell label="身高" value={`${artist.height} cm`} />
<MetaCell label="CV" value={artist.cv ?? "未公开"} />
</div>
{/* 排名卡 */}
<RankCard artist={artist} allArtists={allArtists} />
{/* CTA */}
<div className="flex gap-3 pt-1">
<Button
variant="primary"
@ -133,7 +159,7 @@ export default function ArtistDetailContent({
pulse
className="flex-1"
leftIcon={<Heart size={16} fill="currentColor" />}
onClick={() => setVoteOpen(true)}
onClick={() => openVote(artist)}
>
TA
</Button>
@ -142,6 +168,7 @@ export default function ArtistDetailContent({
size="lg"
leftIcon={<Share2 size={14} />}
aria-label="分享"
onClick={handleShare}
>
<span className="hidden sm:inline"></span>
</Button>
@ -150,7 +177,6 @@ export default function ArtistDetailContent({
</div>
</section>
{/* 15s 表演视频 */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<SectionHeading title="表演视频" subtitle="15s Performance" />
<PerformanceVideo themeColor={artist.themeColor} duration="00:15" />
@ -159,7 +185,6 @@ export default function ArtistDetailContent({
</p>
</section>
{/* 表演图片 */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<SectionHeading title="表演图片" subtitle="Performance Gallery" />
<PerformanceGallery
@ -168,7 +193,6 @@ export default function ArtistDetailContent({
/>
</section>
{/* 详细简介 */}
<section className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<SectionHeading title="艺人简介" subtitle="Biography" />
<div className="bg-surface/50 backdrop-blur-md border border-white/[0.08] rounded-xl p-5 sm:p-7">
@ -186,15 +210,9 @@ export default function ArtistDetailContent({
</div>
</section>
{/* 浮动投票按钮 */}
<FloatingVoteButton onClick={() => setVoteOpen(true)} />
<FloatingVoteButton onClick={() => openVote(artist)} />
{/* 投票弹窗 */}
<VoteModal
artist={voteOpen ? artist : null}
onClose={() => setVoteOpen(false)}
onConfirm={handleVote}
/>
<VoteModal artist={target} onClose={closeVote} onConfirm={confirmVote} />
</>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import toast from "react-hot-toast";
import { useVoteStore } from "@/lib/store";
import type { Artist } from "@/types/artist";
interface UseVoteActionResult {
/** 当前投票目标艺人null 时弹窗关闭) */
target: Artist | null;
/** 触发投票(自动检查登录态) */
openVote: (artist: Artist) => void;
/** 关闭投票弹窗 */
closeVote: () => void;
/** 确认投票(已登录态下调用) */
confirmVote: (artist: Artist, count: number) => Promise<void>;
}
/**
*
*
* -
* - store + API
* - toast
*/
export function useVoteAction(): UseVoteActionResult {
const router = useRouter();
const pathname = usePathname();
const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote);
const remainingVotes = useVoteStore((s) => s.remainingVotes);
const [target, setTarget] = useState<Artist | null>(null);
const openVote = useCallback(
(artist: Artist) => {
if (status === "loading") {
// 会话还在加载,等一下;用户可以再点
return;
}
if (status === "unauthenticated") {
toast("请先登录后再为偶像投票", { icon: "🔐" });
const back = encodeURIComponent(pathname || "/");
setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350);
return;
}
if (remainingVotes <= 0) {
toast.error("今日票数已用完,明天再来吧~");
return;
}
setTarget(artist);
},
[status, pathname, router, remainingVotes],
);
const closeVote = useCallback(() => setTarget(null), []);
const confirmVote = useCallback(
async (artist: Artist, count: number) => {
// 1. 立即更新本地 store + 反馈UI 0 延迟)
recordVote(artist.id, count);
toast.success(`已为 ${artist.name} 投出 ${count}`);
setTarget(null);
// 2. 后台 fire-and-forget 调用真实 API5 秒超时,失败静默忽略)
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
fetch("/api/vote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artistId: artist.id, count }),
signal: ctrl.signal,
})
.catch(() => {})
.finally(() => clearTimeout(timer));
},
[recordVote],
);
return { target, openVote, closeVote, confirmVote };
}

View File

@ -73,8 +73,10 @@ 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);
// 票数采用反比例衰减(确定性 · 避免 SSR/CSR hydration 不一致)
// 主曲线125000/√rank · 用 idx 推导抖动量保持稳定分布
const jitter = ((idx * 1103) % 2999) - 1499;
const votes = Math.round(125000 / Math.sqrt(rank) + jitter);
return {
id: no,

View File

@ -46,13 +46,15 @@ export const MOCK_USER: MockUser = {
};
export function getFanSupports(): FanSupport[] {
// 已应援票数采用确定性映射(避免 hydration 不一致)
const VOTED_BY_ID: Record<string, number> = { "001": 42, "005": 28, "014": 17 };
return MOCK_USER.supportingIds
.map((id) => {
const artist = ARTISTS.find((a) => a.id === id);
if (!artist) return null;
return {
artist,
votedCount: Math.floor(Math.random() * 40) + 10,
votedCount: VOTED_BY_ID[id] ?? 10,
};
})
.filter((x): x is FanSupport => x !== null);

53
src/lib/store.ts Normal file
View File

@ -0,0 +1,53 @@
import { create } from "zustand";
import { ARTISTS } from "./mock-data";
import type { Artist } from "@/types/artist";
interface VoteStore {
/** 当前所有艺人(含动态票数 / 实时排名) */
artists: Artist[];
/** 用户今日已用 / 剩余票数mock */
remainingVotes: number;
usedVotes: number;
dailyQuota: number;
/** 给艺人投票(本地模拟,会重新排名) */
vote: (artistId: string, count: number) => void;
/** 重置(开发时用) */
reset: () => void;
}
function rank(list: Artist[]): Artist[] {
return [...list]
.sort((a, b) => b.votes - a.votes)
.map((a, i) => ({ ...a, rank: i + 1 }));
}
const INITIAL = rank(ARTISTS);
export const useVoteStore = create<VoteStore>((set) => ({
artists: INITIAL,
remainingVotes: 9,
usedVotes: 3,
dailyQuota: 12,
vote: (artistId, count) =>
set((state) => {
const updated = state.artists.map((a) =>
a.id === artistId ? { ...a, votes: a.votes + count } : a,
);
return {
artists: rank(updated),
remainingVotes: Math.max(0, state.remainingVotes - count),
usedVotes: state.usedVotes + count,
};
}),
reset: () =>
set({
artists: INITIAL,
remainingVotes: 9,
usedVotes: 3,
}),
}));
/** 便捷选择器:按 ID 获取最新艺人 */
export function selectArtist(id: string) {
return (s: VoteStore) => s.artists.find((a) => a.id === id);
}