UI-UX/src/components/VoteModal.tsx
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- 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>
2026-05-15 20:14:57 +08:00

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,
);
}