@@ -212,7 +209,13 @@ export default function ArtistDetailContent({
openVote(artist)} />
-
+
>
);
}
@@ -247,13 +250,14 @@ function SectionHeading({
subtitle: string;
}) {
return (
-
-
- {subtitle}
-
-
- ✦ {title}
+
+
+
+ {title}
+
+ {subtitle}
+
);
}
diff --git a/src/components/auth/AuthMenu.tsx b/src/components/auth/AuthMenu.tsx
new file mode 100644
index 0000000..b188b5a
--- /dev/null
+++ b/src/components/auth/AuthMenu.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useSession, signOut } from "next-auth/react";
+import { LogOut } from "lucide-react";
+import { useLoginModalStore } from "@/lib/login-modal-store";
+
+/**
+ * 导航栏 **右侧** 的登录入口:
+ * - 未登录:紫色描边胶囊「登录 / 注册」→ 点击弹出 LoginModal
+ * - 已登录:头像 + 昵称 胶囊 → 点击下方展开下拉,含「退出登录」
+ *
+ * 中部的「我的」链接由 NavLinks 处理(独立组件),不在本组件内。
+ */
+export default function AuthMenu() {
+ const { data: session, status } = useSession();
+ const openLogin = useLoginModalStore((s) => s.show);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ if (!menuOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (!rootRef.current?.contains(e.target as Node)) {
+ setMenuOpen(false);
+ }
+ };
+ const esc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setMenuOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ document.addEventListener("keydown", esc);
+ return () => {
+ document.removeEventListener("mousedown", handler);
+ document.removeEventListener("keydown", esc);
+ };
+ }, [menuOpen]);
+
+ const user = session?.user;
+ const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
+
+ // 未登录态:紫色描边胶囊按钮
+ if (status !== "authenticated") {
+ return (
+
+ );
+ }
+
+ // 已登录态:头像 + 昵称 + 下拉(紫色渐变实心胶囊,高亮用户身份)
+ return (
+
+
+
+ {menuOpen && (
+
+
+
登录账号
+
+ {user?.name}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/auth/GlobalLoginModal.tsx b/src/components/auth/GlobalLoginModal.tsx
new file mode 100644
index 0000000..b3b0a69
--- /dev/null
+++ b/src/components/auth/GlobalLoginModal.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { useLoginModalStore } from "@/lib/login-modal-store";
+import LoginModal from "./LoginModal";
+
+/**
+ * 全局登录弹窗 · 挂在 Providers 树最上层。
+ * 调用 `useLoginModalStore.show(redirectTo?)` 即可唤起。
+ * 登录成功后:有 redirectTo 则 push 过去;无则当前页刷新。
+ */
+export default function GlobalLoginModal() {
+ const router = useRouter();
+ const open = useLoginModalStore((s) => s.open);
+ const redirectTo = useLoginModalStore((s) => s.redirectTo);
+ const setOpen = useLoginModalStore((s) => s.setOpen);
+
+ const handleSuccess = useCallback(() => {
+ if (redirectTo) {
+ router.push(redirectTo);
+ router.refresh();
+ } else if (typeof window !== "undefined") {
+ window.location.reload();
+ }
+ }, [redirectTo, router]);
+
+ return (
+ setOpen(false)}
+ onSuccess={handleSuccess}
+ />
+ );
+}
diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx
new file mode 100644
index 0000000..9883797
--- /dev/null
+++ b/src/components/auth/LoginModal.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import { signIn } from "next-auth/react";
+import { X, Phone, KeyRound, Loader2 } from "lucide-react";
+import Button from "@/components/ui/Button";
+import Logo from "@/components/Logo";
+import { cn } from "@/lib/cn";
+
+interface LoginModalProps {
+ open: boolean;
+ onClose: () => void;
+ /** 登录成功回调(默认刷新页面) */
+ onSuccess?: () => void;
+}
+
+/**
+ * 登录 / 注册弹窗。
+ * 替代独立 /login 路由,所有"需要登录"的入口统一弹出此组件。
+ */
+export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps) {
+ const [phone, setPhone] = useState("");
+ const [code, setCode] = useState("");
+ const [countdown, setCountdown] = useState(0);
+ const [error, setError] = useState(null);
+ const [sending, setSending] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => setMounted(true), []);
+
+ const phoneValid = /^1[3-9]\d{9}$/.test(phone);
+ const codeValid = /^\d{6}$/.test(code);
+
+ // 打开时重置
+ useEffect(() => {
+ if (open) {
+ setPhone("");
+ setCode("");
+ setError(null);
+ setCountdown(0);
+ setSubmitting(false);
+ setSending(false);
+ }
+ }, [open]);
+
+ // ESC + body scroll lock
+ 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 sendOtp = useCallback(async () => {
+ if (!phoneValid) {
+ setError("请输入有效的中国大陆手机号");
+ return;
+ }
+ setError(null);
+ setSending(true);
+ try {
+ const res = await fetch("/api/auth/send-otp", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ phone }),
+ });
+ const data = await res.json();
+ if (!data.ok) throw new Error(data.error?.message || "发送失败");
+ setCountdown(60);
+ const timer = setInterval(() => {
+ setCountdown((c) => {
+ if (c <= 1) {
+ clearInterval(timer);
+ return 0;
+ }
+ return c - 1;
+ });
+ }, 1000);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "发送失败");
+ } finally {
+ setSending(false);
+ }
+ }, [phone, phoneValid]);
+
+ const handleLogin = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!phoneValid || !codeValid) {
+ setError("请检查手机号和验证码");
+ return;
+ }
+ setError(null);
+ setSubmitting(true);
+ try {
+ const result = await signIn("phone-otp", {
+ phone,
+ code,
+ redirect: false,
+ });
+ if (result?.error) {
+ setError("验证码错误或已失效");
+ } else {
+ onClose();
+ if (onSuccess) onSuccess();
+ else if (typeof window !== "undefined") window.location.reload();
+ }
+ } catch {
+ setError("登录失败,请重试");
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ [phone, code, phoneValid, codeValid, onClose, onSuccess],
+ );
+
+ if (!mounted) return null;
+
+ return createPortal(
+
+ {open && (
+
+ {/* 遮罩 */}
+
+
+ {/* 弹窗主体 */}
+
+
+
+
+
+
+
+
+ Sign in to Vote
+
+
+
+
+
+
+ )}
+ ,
+ document.body,
+ );
+}
diff --git a/src/components/auth/RemainingVotesBadge.tsx b/src/components/auth/RemainingVotesBadge.tsx
new file mode 100644
index 0000000..2d4dd3d
--- /dev/null
+++ b/src/components/auth/RemainingVotesBadge.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useSession } from "next-auth/react";
+import { useVoteStore, selectRemaining, DAILY_VOTE_QUOTA } from "@/lib/store";
+
+/**
+ * 导航栏右侧的"今日剩余票数"徽章。
+ * - 仅登录态显示
+ * - 实时从 vote store 取剩余票
+ * - 视觉上是 **填充紫渐变胶囊**,与 AuthMenu 的描边/头像胶囊明显区分(信息 vs 操作)
+ */
+export default function RemainingVotesBadge() {
+ const { status } = useSession();
+ const remaining = useVoteStore(selectRemaining);
+
+ if (status !== "authenticated") return null;
+
+ return (
+
+
+ 今日剩余
+
+
+ {remaining}
+
+
+ / {DAILY_VOTE_QUOTA}
+
+
+ );
+}
diff --git a/src/components/cards/ArtistCard.tsx b/src/components/cards/ArtistCard.tsx
index a21bb72..d494887 100644
--- a/src/components/cards/ArtistCard.tsx
+++ b/src/components/cards/ArtistCard.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import { Heart } from "lucide-react";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
import ArtistPortrait from "./ArtistPortrait";
@@ -25,10 +26,10 @@ export default function ArtistCard({
return (
- {/* 立绘区 */}
-
-
+ {/* 立绘区(13+ 卡片轻度暗化) */}
+
+
- {/* 编号徽章(左上) */}
-
- No.{artist.no}
-
-
- {/* 排名徽章(右上) */}
+ {/* 排名徽章(左上独立紫色圆) */}
{artist.rank}
- {/* 顶部渐变蒙层(让编号更清晰) */}
-
+ {/* 顶部轻微渐变蒙层 */}
+
- {/* 信息区 */}
-
-
- {artist.name}{" "}
-
· {artist.enName}
+ {/* 信息区(黑色背景明显分隔) */}
+
+
+ No.{artist.no}
-
+
+ {artist.name}
+
+
{artist.slogan}
- ❤ {formatVotes(artist.votes)} 票
+
+ {formatVotes(artist.votes)} 票
- {/* 投票按钮 */}
-
+ {/* 投票按钮(所有排名统一样式 · 紫色实心) */}
+
diff --git a/src/components/me/MyFanSupport.tsx b/src/components/me/MyFanSupport.tsx
index dd88058..eef23a8 100644
--- a/src/components/me/MyFanSupport.tsx
+++ b/src/components/me/MyFanSupport.tsx
@@ -1,5 +1,5 @@
import Link from "next/link";
-import { AlertTriangle } from "lucide-react";
+import { Heart, AlertTriangle } from "lucide-react";
import type { FanSupport } from "@/lib/mock-user";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
@@ -10,7 +10,7 @@ export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
还没有应援的艺人 ·{" "}
- 去发现 →
+ 去发现
);
@@ -26,12 +26,16 @@ export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
href={`/artist/${artist.id}`}
className={cn(
"flex items-center gap-3 p-3 rounded-xl border transition-all",
- inTop12
- ? "bg-purple-500/[0.05] border-purple-500/30 hover:bg-purple-500/[0.08]"
- : "bg-pink-500/[0.04] border-pink-500/25 hover:bg-pink-500/[0.06]",
+ "bg-surface/60 hover:bg-surface/80",
+ inTop12 ? "border-purple-500/35" : "border-white/[0.08]",
)}
>
-
+
-
+
+
已投 {votedCount} 票
当前 #{artist.rank}
diff --git a/src/components/me/QuotaCard.tsx b/src/components/me/QuotaCard.tsx
index a2b88a8..49cd85d 100644
--- a/src/components/me/QuotaCard.tsx
+++ b/src/components/me/QuotaCard.tsx
@@ -1,61 +1,100 @@
"use client";
-import { Heart, Users } from "lucide-react";
+import { UserPlus } from "lucide-react";
interface QuotaCardProps {
- /** 我累计投出的票数 */
- total: number;
+ /** 今日剩余票数 */
+ remaining: number;
+ /** 每日总额度(用于「重置为 X 票」展示) */
+ dailyQuota: number;
+ /** 我累计投出的票数(保留 prop 以兼容现有调用方,组件内不直接展示) */
+ cumulative?: number;
onInvite?: () => void;
}
-export default function QuotaCard({ total, onInvite }: QuotaCardProps) {
+export default function QuotaCard({
+ remaining,
+ dailyQuota,
+ onInvite,
+}: QuotaCardProps) {
return (
-
- {/* 装饰星点 */}
-
✦
-
- ✧
-
-
- {/* 高光 */}
+
+ {/* 装饰:右侧紫色光晕 */}
+ {/* 装饰:右侧"水晶"占位(无素材时用 CSS 渲染的辉光六边形) */}
+
-
+
-
-
- 我累计投出
-
-
- {total.toLocaleString()}{" "}
- 票
-
-
- 为偶像应援无上限 · 越投越精彩
+
今日剩余票数
+
+
+ {remaining}
+
+ 票
+
+ 明日 00:00 重置为{" "}
+
+ {dailyQuota}
+ {" "}
+ 票
+
-
-
- 喊好友一起来
+
+
+ 获取更多票数
);
}
+
+/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */
+function CrystalDecoration() {
+ return (
+
+ );
+}
diff --git a/src/components/me/SignInCalendar.tsx b/src/components/me/SignInCalendar.tsx
index 75346dc..b92d24c 100644
--- a/src/components/me/SignInCalendar.tsx
+++ b/src/components/me/SignInCalendar.tsx
@@ -1,13 +1,17 @@
"use client";
-import { Check, Gift } from "lucide-react";
+import { Check } from "lucide-react";
import { cn } from "@/lib/cn";
interface SignInCalendarProps {
+ /** 本周 7 天签到状态(周一→周日) */
weekly: boolean[];
+ /** 今日是否已签到 */
todaySigned: boolean;
- /** 今天是周几(0 周日 ~ 6 周六),用 1~7 对应周一~周日 */
+ /** 今天是周几(0~6,对应周一~周日) */
todayIndex?: number;
+ /** 连续签到天数(用于今日格子显示「第 N 天」) */
+ streak?: number;
onSignIn?: () => void;
}
@@ -17,58 +21,59 @@ export default function SignInCalendar({
weekly,
todaySigned,
todayIndex,
+ streak = 0,
onSignIn,
}: SignInCalendarProps) {
// 默认今天 = 数组中第一个未签到(或最后一个 true 之后)
const computedToday =
todayIndex ??
- (weekly.indexOf(false) === -1 ? weekly.length - 1 : weekly.indexOf(false));
+ (weekly.indexOf(false) === -1
+ ? weekly.length - 1
+ : weekly.indexOf(false));
return (
-
-
- {WEEK_LABELS.map((label, i) => {
- const signed = weekly[i];
- const isToday = i === computedToday;
- return (
-
- );
- })}
-
-
-
- 每日签到 · 解锁专属应援徽章 · 中断后从头计算
-
+
+ {WEEK_LABELS.map((label, i) => {
+ const signed = weekly[i];
+ const isToday = i === computedToday;
+ const clickable = isToday && !todaySigned;
+ return (
+
+ );
+ })}
);
}
diff --git a/src/components/me/StatsGrid.tsx b/src/components/me/StatsGrid.tsx
index 1eba8dd..7c17b0e 100644
--- a/src/components/me/StatsGrid.tsx
+++ b/src/components/me/StatsGrid.tsx
@@ -1,8 +1,8 @@
-import { Heart, Star, Calendar, UserPlus } from "lucide-react";
+import { Sparkles, Star, Calendar, UserPlus } from "lucide-react";
import type { MockUser } from "@/lib/mock-user";
const ICON_MAP = {
- votes:
,
+ votes:
,
fan:
,
signin:
,
invite:
,
@@ -36,14 +36,19 @@ export default function StatsGrid({ user }: { user: MockUser }) {
{stats.map((s) => (
-
- {s.value}
-
-
-
{s.icon}
- {s.label}
+
+ {s.icon}
+
+
+
+ {s.value}
+
+
{s.label}
))}
diff --git a/src/components/me/UserHeader.tsx b/src/components/me/UserHeader.tsx
index f1a9d5b..f22dfa9 100644
--- a/src/components/me/UserHeader.tsx
+++ b/src/components/me/UserHeader.tsx
@@ -1,45 +1,71 @@
-import { Pencil } from "lucide-react";
+import { Pencil, Star, LogOut } from "lucide-react";
import type { MockUser } from "@/lib/mock-user";
-export default function UserHeader({ user }: { user: MockUser }) {
+interface UserHeaderProps {
+ user: MockUser;
+ onLogout?: () => void;
+}
+
+export default function UserHeader({ user, onLogout }: UserHeaderProps) {
const initial = user.nickname.charAt(0).toUpperCase();
+ // 简单的等级算法:每 50 票升 1 级,从 1 起步
+ const level = Math.max(1, Math.floor(user.totalVotes / 50) + 1);
+
return (
-
- {/* 头像 */}
-
-
- {initial}
+
+ {/* 头像 + 等级角标 */}
+
- @{user.nickname}
+ {user.nickname}
-
+
ID: {user.id}
- |
+ ·
已连续签到{" "}
-
+
{user.signInStreak}
{" "}
天
-
+
+
+ {onLogout && (
+
+ )}
+
);
}
diff --git a/src/components/ranking/DebutLineDivider.tsx b/src/components/ranking/DebutLineDivider.tsx
index 142b49c..3b37c7c 100644
--- a/src/components/ranking/DebutLineDivider.tsx
+++ b/src/components/ranking/DebutLineDivider.tsx
@@ -1,7 +1,8 @@
+import { AlertTriangle } from "lucide-react";
+
export default function DebutLineDivider() {
return (
-
- {/* 横线 */}
+
- {/* 中央徽章 */}
-
-
⚠
-
- Debut Line · 出道线
+
+
+
+ 出道线 · Debut Line
-
⚠
);
diff --git a/src/components/ranking/RankingRow.tsx b/src/components/ranking/RankingRow.tsx
index 9fcb760..705f822 100644
--- a/src/components/ranking/RankingRow.tsx
+++ b/src/components/ranking/RankingRow.tsx
@@ -3,7 +3,6 @@
import Link from "next/link";
import { TrendingUp, AlertTriangle } from "lucide-react";
import type { Artist } from "@/types/artist";
-import { getRankCategory } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
@@ -13,11 +12,16 @@ interface RankingRowProps {
gapAbove?: number;
/** 与出道线差距(票数):候补区第一位用于"差 X 票进出道位" */
gapToDebut?: number;
- /** 是否为出道线下第一位(用于"救援投票") */
+ /** 是否为出道线下第一位 */
isRescue?: boolean;
onVote: (a: Artist) => void;
}
+function formatVotes(v: number): string {
+ if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
+ return v.toLocaleString();
+}
+
export default function RankingRow({
artist,
gapAbove,
@@ -25,25 +29,22 @@ export default function RankingRow({
isRescue = false,
onVote,
}: RankingRowProps) {
- const cat = getRankCategory(artist.rank);
const inTop12 = artist.rank <= 12;
return (
{/* 排名 */}
#{artist.rank}
@@ -54,11 +55,7 @@ export default function RankingRow({
- {artist.name}{" "}
-
- · {artist.enName}
+ {artist.name}
+
+ · {artist.slogan}
-
- {artist.slogan}
-
{/* 票数 */}
-
- {(artist.votes / 10000).toFixed(1)}w
-
-
票
+
+ {formatVotes(artist.votes)} 票
+
- {/* 差距 */}
+ {/* 距上一名 / 差出道线 */}
{isRescue && gapToDebut != null ? (
-
+
- 差 +{gapToDebut.toLocaleString()} 进出道位
+
+{gapToDebut.toLocaleString()}
) : gapAbove != null && artist.rank > 1 ? (
-
-
−
- {gapAbove.toLocaleString()}
+
+
+ −{gapAbove.toLocaleString()}
) : (
—
)}
- {/* 投票按钮 */}
+ {/* 投票按钮(统一紫色实心) */}
);
diff --git a/src/components/ranking/Top3Podium.tsx b/src/components/ranking/Top3Podium.tsx
index 46e578e..df31ff7 100644
--- a/src/components/ranking/Top3Podium.tsx
+++ b/src/components/ranking/Top3Podium.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import { Crown } from "lucide-react";
import type { Artist } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
@@ -7,44 +8,16 @@ interface Top3PodiumProps {
top3: Artist[];
}
-const STYLES = {
- 1: {
- label: "🥇",
- rank: "#1",
- color: "text-[#fcd34d]",
- border: "border-[#fcd34d]",
- glow: "shadow-[0_0_28px_rgba(252,211,77,0.45)]",
- bg: "bg-gradient-to-b from-[#fcd34d22] to-transparent",
- scale: "lg:scale-110",
- size: "w-28 h-28 sm:w-32 sm:h-32",
- },
- 2: {
- label: "🥈",
- rank: "#2",
- color: "text-[#c4ccd8]",
- border: "border-[#c4ccd8]",
- glow: "shadow-[0_0_20px_rgba(196,204,216,0.3)]",
- bg: "bg-gradient-to-b from-[#c4ccd822] to-transparent",
- scale: "",
- size: "w-24 h-24 sm:w-28 sm:h-28",
- },
- 3: {
- label: "🥉",
- rank: "#3",
- color: "text-[#cd7f32]",
- border: "border-[#cd7f32]",
- glow: "shadow-[0_0_20px_rgba(205,127,50,0.3)]",
- bg: "bg-gradient-to-b from-[#cd7f3222] to-transparent",
- scale: "",
- size: "w-24 h-24 sm:w-28 sm:h-28",
- },
-} as const;
+function formatVotes(v: number): string {
+ if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
+ return v.toLocaleString();
+}
export default function Top3Podium({ top3 }: Top3PodiumProps) {
const [first, second, third] = top3;
if (!first || !second || !third) return null;
- // 视觉上:第二名 · 第一名 · 第三名 的顺序排列
+ // 视觉排序:第二 / 第一(中央) / 第三
const order: Array<{ artist: Artist; rank: 1 | 2 | 3 }> = [
{ artist: second, rank: 2 },
{ artist: first, rank: 1 },
@@ -52,34 +25,35 @@ export default function Top3Podium({ top3 }: Top3PodiumProps) {
];
return (
-
+
{order.map(({ artist, rank }) => {
- const style = STYLES[rank];
+ const isFirst = rank === 1;
return (
+ {/* 顶部小皇冠(仅第一名) */}
+ {isFirst && (
+
+
+
+ )}
+
+ {/* 头像(圆形 + 紫色环) */}
- {style.label}
- {style.rank}
-
-
-
+
+ {/* 名字 */}
+
{artist.name}
-
- {artist.enName}
-
+
+ {/* 票数 */}
- {(artist.votes / 10000).toFixed(1)}w
+ {formatVotes(artist.votes)}{" "}
+ 票
+
+
+ {/* 当前排名徽章 */}
+
+ 当前 #{artist.rank}
-
票
);
})}
diff --git a/src/components/ui/Countdown.tsx b/src/components/ui/Countdown.tsx
index 18d8dd4..f974e77 100644
--- a/src/components/ui/Countdown.tsx
+++ b/src/components/ui/Countdown.tsx
@@ -83,15 +83,15 @@ export default function Countdown({
return (
-
- ⏱
-
-
- {time.days}d {String(time.hours).padStart(2, "0")}:
+ 距离投票结束
+
+ {time.days}天 {String(time.hours).padStart(2, "0")}:
{String(time.minutes).padStart(2, "0")}:
{String(time.seconds).padStart(2, "0")}
diff --git a/src/hooks/useVoteAction.ts b/src/hooks/useVoteAction.ts
index 9cb1c47..793dbcf 100644
--- a/src/hooks/useVoteAction.ts
+++ b/src/hooks/useVoteAction.ts
@@ -1,16 +1,24 @@
"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 {
+ useVoteStore,
+ selectRemaining,
+ DAILY_VOTE_QUOTA,
+} from "@/lib/store";
+import { useLoginModalStore } from "@/lib/login-modal-store";
import type { Artist } from "@/types/artist";
interface UseVoteActionResult {
/** 当前投票目标艺人(null 时弹窗关闭) */
target: Artist | null;
- /** 触发投票(自动检查登录态) */
+ /** 今日剩余票数 */
+ remaining: number;
+ /** 每日总额度(常量,供 UI 文案展示) */
+ dailyQuota: number;
+ /** 触发投票(自动检查登录态 + 额度) */
openVote: (artist: Artist) => void;
/** 关闭投票弹窗 */
closeVote: () => void;
@@ -21,39 +29,47 @@ interface UseVoteActionResult {
/**
* 投票交互统一入口。
*
- * - 未登录 → 提示并跳登录页(登录后回到当前路径)
- * - 已登录 → 打开投票弹窗 → 确认后调用本地 store + 尝试调用 API
- * - 任意态 → 用 toast 反馈结果
- *
- * 注意:当前已取消所有投票数量限制(无每日上限 / 无单艺人上限)。
+ * 规则:
+ * - 每用户每日总额度 = 10 票,跨艺人共享。无单艺人上限。
+ * - 未登录 → toast 提示并跳登录页
+ * - 已登录但当日票数已用完 → toast 提示,不打开弹窗
+ * - 弹窗确认后:本地 store 立即扣减 + 调用后端 API(fire-and-forget)
*/
export function useVoteAction(): UseVoteActionResult {
- const router = useRouter();
- const pathname = usePathname();
const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote);
+ const remaining = useVoteStore(selectRemaining);
+ const openLogin = useLoginModalStore((s) => s.show);
const [target, setTarget] = useState(null);
const openVote = useCallback(
(artist: Artist) => {
- if (status === "loading") return; // 会话还在加载,等一下
+ if (status === "loading") return;
if (status === "unauthenticated") {
- toast("请先登录后再为偶像投票", { icon: "🔐" });
- const back = encodeURIComponent(pathname || "/");
- setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350);
+ toast("请先登录后再为偶像投票");
+ setTimeout(openLogin, 350);
+ return;
+ }
+ if (remaining <= 0) {
+ toast("今日票数已用完,明天再来吧");
return;
}
setTarget(artist);
},
- [status, pathname, router],
+ [status, openLogin, remaining],
);
const closeVote = useCallback(() => setTarget(null), []);
const confirmVote = useCallback(
async (artist: Artist, count: number) => {
- // 1. 立即更新本地 store + 反馈(UI 0 延迟)
- recordVote(artist.id, count);
+ // 1. 本地 store 立即扣减(包含额度校验)
+ const success = recordVote(artist.id, count);
+ if (!success) {
+ toast.error("今日票数不足");
+ setTarget(null);
+ return;
+ }
toast.success(`已为 ${artist.name} 投出 ${count} 票`);
setTarget(null);
@@ -72,5 +88,12 @@ export function useVoteAction(): UseVoteActionResult {
[recordVote],
);
- return { target, openVote, closeVote, confirmVote };
+ return {
+ target,
+ remaining,
+ dailyQuota: DAILY_VOTE_QUOTA,
+ openVote,
+ closeVote,
+ confirmVote,
+ };
}
diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts
index 10fa759..cea05aa 100644
--- a/src/lib/api-response.ts
+++ b/src/lib/api-response.ts
@@ -30,6 +30,8 @@ export const ERR = {
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
+ QUOTA_EXHAUSTED: (msg = "今日票数已用完") =>
+ err("QUOTA_EXHAUSTED", msg, 409),
};
/**
diff --git a/src/lib/login-modal-store.ts b/src/lib/login-modal-store.ts
new file mode 100644
index 0000000..c927099
--- /dev/null
+++ b/src/lib/login-modal-store.ts
@@ -0,0 +1,17 @@
+import { create } from "zustand";
+
+interface LoginModalStore {
+ open: boolean;
+ /** 登录成功后要跳转的路径。空值表示就地刷新当前页。 */
+ redirectTo?: string;
+ setOpen: (open: boolean) => void;
+ /** 唤起登录弹窗。可选传 redirectTo —— 登录成功后会 router.push 过去。 */
+ show: (redirectTo?: string) => void;
+}
+
+export const useLoginModalStore = create((set) => ({
+ open: false,
+ redirectTo: undefined,
+ setOpen: (open) => set({ open }),
+ show: (redirectTo) => set({ open: true, redirectTo }),
+}));
diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts
index c679479..a6bc31f 100644
--- a/src/lib/mock-data.ts
+++ b/src/lib/mock-data.ts
@@ -38,17 +38,18 @@ const STAGE_NAMES: Array<[string, string, string]> = [
["梓", "AZUR", "蓝调诗人"],
];
+// 4 个新主标签均匀分布。每位艺人 1-2 个标签,便于筛选器命中。
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"],
+ ["all-rounder"],
+ ["rap"],
+ ["vocal", "all-rounder"],
+ ["dance", "all-rounder"],
+ ["rap", "all-rounder"],
+ ["vocal"],
+ ["dance"],
+ ["all-rounder"],
["rap"],
["vocal", "dance"],
];
diff --git a/src/lib/mock-user.ts b/src/lib/mock-user.ts
index ea6c7a0..d81c496 100644
--- a/src/lib/mock-user.ts
+++ b/src/lib/mock-user.ts
@@ -11,7 +11,7 @@ export interface MockUser {
weeklySignIn: boolean[];
/** 今日是否已签到 */
todaySignedIn: boolean;
- /** 累计投票数(无上限) */
+ /** 累计投票数 */
totalVotes: number;
/** 应援的艺人 ID 列表 */
supportingIds: string[];
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 583e09b..61fe9ba 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -2,17 +2,32 @@ import { create } from "zustand";
import { ARTISTS } from "./mock-data";
import type { Artist } from "@/types/artist";
+/** 每日基础投票额度(与后端 ActivityConfig.dailyQuota 对齐) */
+export const DAILY_VOTE_QUOTA = 10;
+
interface VoteStore {
/** 当前所有艺人(含动态票数 / 实时排名) */
artists: Artist[];
- /** 用户累计投出票数(无上限) */
+ /** 累计已投票数 */
myTotalVotes: number;
- /** 给艺人投票(本地模拟,会重新排名) */
- vote: (artistId: string, count: number) => void;
+ /** 今日已用票数(跨日自动重置) */
+ usedToday: number;
+ /** 今日额度日期标记(YYYY-M-D,按本地时区) */
+ quotaDate: string;
+ /**
+ * 给艺人投票(本地模拟,会重新排名)。
+ * 票数不足时返回 false,前端可据此提示。
+ */
+ vote: (artistId: string, count: number) => boolean;
/** 重置(开发时用) */
reset: () => void;
}
+function todayKey(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
+}
+
function rank(list: Artist[]): Artist[] {
return [...list]
.sort((a, b) => b.votes - a.votes)
@@ -24,20 +39,36 @@ const INITIAL = rank(ARTISTS);
export const useVoteStore = create((set) => ({
artists: INITIAL,
myTotalVotes: 0,
- vote: (artistId, count) =>
+ usedToday: 0,
+ quotaDate: todayKey(),
+ vote: (artistId, count) => {
+ let success = false;
set((state) => {
+ const today = todayKey();
+ const baseUsed = state.quotaDate === today ? state.usedToday : 0;
+ const remaining = DAILY_VOTE_QUOTA - baseUsed;
+ if (count <= 0 || count > remaining) {
+ return state;
+ }
+ success = true;
const updated = state.artists.map((a) =>
a.id === artistId ? { ...a, votes: a.votes + count } : a,
);
return {
artists: rank(updated),
myTotalVotes: state.myTotalVotes + count,
+ usedToday: baseUsed + count,
+ quotaDate: today,
};
- }),
+ });
+ return success;
+ },
reset: () =>
set({
artists: INITIAL,
myTotalVotes: 0,
+ usedToday: 0,
+ quotaDate: todayKey(),
}),
}));
@@ -45,3 +76,8 @@ export const useVoteStore = create((set) => ({
export function selectArtist(id: string) {
return (s: VoteStore) => s.artists.find((a) => a.id === id);
}
+
+/** 选择器:当前剩余票数(基于今日已用) */
+export function selectRemaining(s: VoteStore): number {
+ return Math.max(0, DAILY_VOTE_QUOTA - s.usedToday);
+}
diff --git a/src/types/artist.ts b/src/types/artist.ts
index 48e11a7..42600c9 100644
--- a/src/types/artist.ts
+++ b/src/types/artist.ts
@@ -48,7 +48,7 @@ export interface Artist {
export const TAG_LABEL: Record = {
vocal: "声乐担当",
dance: "舞蹈担当",
- rap: "Rap 担当",
+ rap: "rap担当",
"all-rounder": "全能型",
visual: "颜值担当",
leader: "队长担当",