Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - store 改为 votedArtists[] + zustand persist - VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额) - 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标 - Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度) - /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport) 后端: - votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS) - /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底 - /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota - 新增 ERR.ALREADY_VOTED 错误码 测试: - DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh) - 修复 P2003 FK 违反未识别的 bug 详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { X, Heart, AlertCircle, Check } from "lucide-react";
|
|
import type { Artist } from "@/types/artist";
|
|
import { cn } from "@/lib/cn";
|
|
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
|
import Button from "./ui/Button";
|
|
import ArtistPortrait from "./cards/ArtistPortrait";
|
|
|
|
interface VoteModalProps {
|
|
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
|
artist: Artist | null;
|
|
/** 剩余可投票数(终身 12 - 已投) */
|
|
remaining: number;
|
|
/** 总额度常量 12(用于文案 "X / 12") */
|
|
totalQuota: number;
|
|
/** 关闭弹窗 */
|
|
onClose: () => void;
|
|
/** 确认投票(无 count 参数,固定 1 票) */
|
|
onConfirm: (artist: Artist) => void | Promise<void>;
|
|
}
|
|
|
|
export default function VoteModal({
|
|
artist,
|
|
remaining,
|
|
totalQuota,
|
|
onClose,
|
|
onConfirm,
|
|
}: VoteModalProps) {
|
|
const open = artist != null;
|
|
const [loading, setLoading] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
// 即时判断当前艺人是否已被投过(避免父组件忘传防护)
|
|
const hasVotedSelector = artist ? selectHasVoted(artist.id) : () => false;
|
|
const hasVoted = useVoteStore(hasVotedSelector);
|
|
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
// 打开时重置 loading 态
|
|
useEffect(() => {
|
|
if (open) setLoading(false);
|
|
}, [open]);
|
|
|
|
// ESC 关闭 + body 锁滚
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
window.removeEventListener("keydown", handler);
|
|
document.body.style.overflow = prev;
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
const exhausted = remaining <= 0;
|
|
const canSubmit = !exhausted && !hasVoted && !loading;
|
|
|
|
const handleConfirm = useCallback(async () => {
|
|
if (!artist || !canSubmit) return;
|
|
setLoading(true);
|
|
try {
|
|
await onConfirm(artist);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [artist, canSubmit, onConfirm]);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(
|
|
<AnimatePresence>
|
|
{open && artist && (
|
|
<motion.div
|
|
key="vote-modal-root"
|
|
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.2 }}
|
|
>
|
|
{/* 遮罩 */}
|
|
<button
|
|
type="button"
|
|
aria-label="关闭弹窗"
|
|
onClick={onClose}
|
|
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
|
|
/>
|
|
|
|
{/* 弹窗主体 */}
|
|
<motion.div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="vote-modal-title"
|
|
className="relative w-full max-w-sm bg-elevated border border-white/14 rounded-2xl p-7 shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_40px_rgba(139,92,246,0.12)]"
|
|
initial={{ opacity: 0, scale: 0.94, y: 16 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
|
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
|
|
>
|
|
{/* 关闭按钮 */}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="absolute top-3.5 right-4 w-7 h-7 flex items-center justify-center text-white/55 hover:text-white transition-colors"
|
|
aria-label="关闭"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
|
|
{/* 头像 */}
|
|
<div className="w-20 h-20 mx-auto mb-3.5 rounded-full overflow-hidden border-2 border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.4)] relative">
|
|
<ArtistPortrait
|
|
artist={artist}
|
|
rounded="rounded-full"
|
|
className="w-full h-full"
|
|
/>
|
|
{hasVoted && (
|
|
<div className="absolute inset-0 bg-black/55 flex items-center justify-center">
|
|
<Check size={28} className="text-purple-300" strokeWidth={3} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 标题 */}
|
|
<div className="text-center mb-3.5">
|
|
<div
|
|
id="vote-modal-title"
|
|
className="text-lg font-bold text-white mb-1"
|
|
>
|
|
{hasVoted
|
|
? `已为 ${artist.name} 投过票`
|
|
: exhausted
|
|
? "12 票已用完"
|
|
: `为 ${artist.name} 投票`}
|
|
</div>
|
|
<div className="font-label text-[11px] tracking-widest text-white/45 uppercase">
|
|
No.{artist.no} · Current Rank #{artist.rank}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 剩余票数显示 */}
|
|
<div
|
|
className={cn(
|
|
"flex items-center justify-between text-xs mb-4 px-3 py-2.5 rounded-lg border",
|
|
exhausted
|
|
? "border-pink-400/30 bg-pink-400/[0.05]"
|
|
: "border-purple-500/25 bg-purple-500/[0.06]",
|
|
)}
|
|
>
|
|
<span className="text-white/65">你的剩余票数</span>
|
|
<span
|
|
className={cn(
|
|
"font-display tabular-nums text-base",
|
|
exhausted ? "text-pink-300" : "text-purple-300",
|
|
)}
|
|
>
|
|
{remaining}{" "}
|
|
<span className="text-white/40 text-xs">/ {totalQuota}</span>
|
|
</span>
|
|
</div>
|
|
|
|
{/* 规则提示 · 不可撤销警示(仅在可投态显示) */}
|
|
{!hasVoted && !exhausted && (
|
|
<div className="flex items-start gap-2 mb-5 px-3 py-2.5 rounded-lg bg-white/[0.03] border border-white/[0.06]">
|
|
<AlertCircle
|
|
size={14}
|
|
className="text-purple-300/80 mt-0.5 flex-shrink-0"
|
|
/>
|
|
<p className="text-[11px] leading-relaxed text-white/65">
|
|
投出后不可撤销 · 每位艺人仅能投 1 票
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 确认按钮 */}
|
|
<Button
|
|
variant="primary"
|
|
className="w-full h-12 text-sm"
|
|
onClick={hasVoted || exhausted ? onClose : handleConfirm}
|
|
loading={loading}
|
|
disabled={loading}
|
|
leftIcon={
|
|
hasVoted || exhausted ? undefined : <Heart size={14} />
|
|
}
|
|
>
|
|
{hasVoted
|
|
? "好的"
|
|
: exhausted
|
|
? "感谢支持"
|
|
: "投出我的一票"}
|
|
</Button>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>,
|
|
document.body,
|
|
);
|
|
}
|