fix(ux): center modals with overlay; live vote with toast; deterministic mock data; cascade layer fix
This commit is contained in:
parent
7949f9bcd1
commit
9fe9fa914f
@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// 关闭左下角的开发指示器(dev overlay 角标)
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -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
52
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 选中文字 ── */
|
||||
|
||||
@ -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
146
src/app/me/MeContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 ?? "粉丝",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 数据合并 mock(API 只返回基础字段,详情仍从 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
47
src/components/Providers.tsx
Normal file
47
src/components/Providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
src/components/SearchModal.tsx
Normal file
276
src/components/SearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/SearchTrigger.tsx
Normal file
37
src/components/SearchTrigger.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/hooks/useVoteAction.ts
Normal file
82
src/hooks/useVoteAction.ts
Normal 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 调用真实 API(5 秒超时,失败静默忽略)
|
||||
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 };
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
53
src/lib/store.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user