UI-UX/src/app/me/MeContent.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

79 lines
2.2 KiB
TypeScript

"use client";
import { useMemo } from "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 MyFanSupport from "@/components/me/MyFanSupport";
import {
useVoteStore,
selectRemaining,
TOTAL_VOTE_QUOTA,
type MySupport,
} from "@/lib/store";
interface MeContentProps {
session: {
id: string;
nickname: string;
};
}
export default function MeContent({ session }: MeContentProps) {
// 订阅 store 原始引用(稳定,仅在 set() 时变更),组件内 useMemo 派生 supports,
// 避免 Zustand v5 + useSyncExternalStore 对"selector 返回新引用"报 infinite-loop 错。
const votedArtists = useVoteStore((s) => s.votedArtists);
const storeArtists = useVoteStore((s) => s.artists);
const remaining = useVoteStore(selectRemaining);
const votedCount = votedArtists.length;
const supports = useMemo<MySupport[]>(() => {
const list: MySupport[] = [];
for (const id of votedArtists) {
const artist = storeArtists.find((a) => a.id === id);
if (artist) list.push({ artist });
}
return list;
}, [votedArtists, storeArtists]);
const handleLogout = () => {
toast("正在退出登录…");
signOut({ callbackUrl: "/" });
};
return (
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader
nickname={session.nickname}
userId={session.id}
onLogout={handleLogout}
/>
<QuotaCard remaining={remaining} totalQuota={TOTAL_VOTE_QUOTA} />
<StatsGrid voted={votedCount} remaining={remaining} />
<section>
<SectionTitle label="我的应援" />
<MyFanSupport supports={supports} />
</section>
</div>
);
}
function SectionTitle({ label }: { label: string }) {
return (
<div className="mb-4 flex items-center gap-2.5">
<span
aria-hidden
className="w-1 h-4 rounded-full bg-purple-400 shadow-[0_0_8px_rgba(167,139,250,0.7)]"
/>
<h2 className="text-sm font-semibold text-white tracking-wider">
{label}
</h2>
</div>
);
}