UI-UX/src/lib/store.ts
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

137 lines
4.6 KiB
TypeScript

import { create } from "zustand";
import { persist } from "zustand/middleware";
import { ARTISTS } from "./mock-data";
import type { Artist } from "@/types/artist";
/**
* 每用户终身投票总额度 —— 一人 12 票,每位艺人最多 1 票,
* 投完即结束,不限时,不可撤销。
*/
export const TOTAL_VOTE_QUOTA = 12;
/** 派生类型:我应援的艺人(新规则下每位仅 1 票,不再带 votedCount) */
export interface MySupport {
artist: Artist;
}
interface VoteStore {
/** 当前所有艺人(含动态票数 / 实时排名) */
artists: Artist[];
/**
* 我已投票的艺人 ID 列表(顺序 = 投票顺序)。
* 用数组而不是 Set:zustand persist 默认 storage 是 JSON,Set 无法直接序列化。
* 数组按投票顺序保留,既能 .includes() 判重,也能在 /me 页面显示"我的投票"时按时间排序。
*/
votedArtists: string[];
/**
* 给艺人投票。
* - 已投过该艺人 → 返回 { ok: false, reason: "already" }
* - 已用满 12 票 → 返回 { ok: false, reason: "exhausted" }
* - 成功 → 返回 { ok: true }
*/
vote: (artistId: string) => { ok: boolean; reason?: "already" | "exhausted" };
/** 重置(开发时用 / 测试用) */
reset: () => void;
}
/** 票数倒序 + 编号升序兜底,确保 0 票时也有稳定排名 */
function rank(list: Artist[]): Artist[] {
return [...list]
.sort((a, b) => b.votes - a.votes || a.no.localeCompare(b.no))
.map((a, i) => ({ ...a, rank: i + 1 }));
}
const INITIAL_ARTISTS = rank(ARTISTS);
export const useVoteStore = create<VoteStore>()(
persist(
(set, get) => ({
artists: INITIAL_ARTISTS,
votedArtists: [],
vote: (artistId) => {
const state = get();
if (state.votedArtists.includes(artistId)) {
return { ok: false, reason: "already" };
}
if (state.votedArtists.length >= TOTAL_VOTE_QUOTA) {
return { ok: false, reason: "exhausted" };
}
const updated = state.artists.map((a) =>
a.id === artistId ? { ...a, votes: a.votes + 1 } : a,
);
set({
artists: rank(updated),
votedArtists: [...state.votedArtists, artistId],
});
return { ok: true };
},
reset: () =>
set({
artists: INITIAL_ARTISTS,
votedArtists: [],
}),
}),
{
name: "cyber-star-vote",
// 仅持久化 votedArtists —— artists 票数/排名是派生数据,
// 刷新后重新从初始数据 + votedArtists 重建。
// 注意:当前 mock 阶段 artists 只反映本地投票,不同步服务端 —— 等后端接入再调整。
partialize: (state) => ({ votedArtists: state.votedArtists }),
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
onRehydrateStorage: () => (state) => {
if (!state) return;
const counts = new Map<string, number>();
for (const id of state.votedArtists) {
counts.set(id, (counts.get(id) ?? 0) + 1);
}
const rebuilt = INITIAL_ARTISTS.map((a) => ({
...a,
votes: a.votes + (counts.get(a.id) ?? 0),
}));
state.artists = rank(rebuilt);
},
},
),
);
/** 选择器:按 ID 获取最新艺人 */
export function selectArtist(id: string) {
return (s: VoteStore) => s.artists.find((a) => a.id === id);
}
/** 选择器:当前剩余票数 = 12 - 已投艺人数 */
export function selectRemaining(s: VoteStore): number {
return Math.max(0, TOTAL_VOTE_QUOTA - s.votedArtists.length);
}
/** 选择器:是否已投过指定艺人(高阶,在组件里用 useVoteStore(selectHasVoted(id))) */
export function selectHasVoted(id: string) {
return (s: VoteStore) => s.votedArtists.includes(id);
}
/** 选择器:12 票是否已全部投完 */
export function selectIsExhausted(s: VoteStore): boolean {
return s.votedArtists.length >= TOTAL_VOTE_QUOTA;
}
/**
* 派生函数:"我的应援"列表 —— 按投票顺序(最早投的在前)。
*
* ⚠️ 不要直接 `useVoteStore(selectMySupports)`:它每次都返回新数组,
* 会触发 React 19 的 "getSnapshot should be cached" 报错。
* 正确用法:在组件里 useMemo 派生(参考 MeContent.tsx)。
*/
export function selectMySupports(s: VoteStore): MySupport[] {
const list: MySupport[] = [];
for (const id of s.votedArtists) {
const artist = s.artists.find((a) => a.id === id);
if (artist) list.push({ artist });
}
return list;
}
/** 选择器:我支持的艺人数 = 投票数 */
export function selectMySupportingCount(s: VoteStore): number {
return s.votedArtists.length;
}