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>
137 lines
4.6 KiB
TypeScript
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;
|
|
}
|