- nav: center links (首页/排行榜/我的), right-side AuthMenu + RemainingVotesBadge; image logo with responsive sizing - auth: replace /login route with global LoginModal triggered anywhere; "我的" intercepts unauth users with post-login redirect - home: full-screen Hero, redesigned Top12 (12 pill cards, top-3 glow), scroll-snap mandatory between Hero/Top12/candidates - home: candidates section with sticky filter that gains frosted-glass bg when stuck (matches nav) - filter: simplified tags (全部/舞蹈/声乐/rap/全能型); ArtistCard uniform purple vote button - ranking/me: remove Top12Bar; me header stacks 编辑资料/退出登录 vertically - typography: font-logo set to Orbitron; ✦ glyph in CYBER ✦ STAR preserved - layout: max-w-[1500px] unified across pages - docs: add design-spec.md + design-spec.html with full visual spec (lucide SVG, zero emoji policy) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
100 lines
3.0 KiB
TypeScript
100 lines
3.0 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import toast from "react-hot-toast";
|
||
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;
|
||
/** 确认投票(已登录态下调用) */
|
||
confirmVote: (artist: Artist, count: number) => Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* 投票交互统一入口。
|
||
*
|
||
* 规则:
|
||
* - 每用户每日总额度 = 10 票,跨艺人共享。无单艺人上限。
|
||
* - 未登录 → toast 提示并跳登录页
|
||
* - 已登录但当日票数已用完 → toast 提示,不打开弹窗
|
||
* - 弹窗确认后:本地 store 立即扣减 + 调用后端 API(fire-and-forget)
|
||
*/
|
||
export function useVoteAction(): UseVoteActionResult {
|
||
const { status } = useSession();
|
||
const recordVote = useVoteStore((s) => s.vote);
|
||
const remaining = useVoteStore(selectRemaining);
|
||
const openLogin = useLoginModalStore((s) => s.show);
|
||
const [target, setTarget] = useState<Artist | null>(null);
|
||
|
||
const openVote = useCallback(
|
||
(artist: Artist) => {
|
||
if (status === "loading") return;
|
||
if (status === "unauthenticated") {
|
||
toast("请先登录后再为偶像投票");
|
||
setTimeout(openLogin, 350);
|
||
return;
|
||
}
|
||
if (remaining <= 0) {
|
||
toast("今日票数已用完,明天再来吧");
|
||
return;
|
||
}
|
||
setTarget(artist);
|
||
},
|
||
[status, openLogin, remaining],
|
||
);
|
||
|
||
const closeVote = useCallback(() => setTarget(null), []);
|
||
|
||
const confirmVote = useCallback(
|
||
async (artist: Artist, count: number) => {
|
||
// 1. 本地 store 立即扣减(包含额度校验)
|
||
const success = recordVote(artist.id, count);
|
||
if (!success) {
|
||
toast.error("今日票数不足");
|
||
setTarget(null);
|
||
return;
|
||
}
|
||
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,
|
||
remaining,
|
||
dailyQuota: DAILY_VOTE_QUOTA,
|
||
openVote,
|
||
closeVote,
|
||
confirmVote,
|
||
};
|
||
}
|