diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a6529..32eabf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,38 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上 --- +## v0.3.3 · 2026-05-15 · 修复:投票后票数 +1 和 Top12 排位要等 30s + +**Commit 信息** +- 完整 diff: [v0.3.2...v0.3.3](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.2...v0.3.3) + +**改了什么** +- 投票成功后,首页 Top12 + 排行榜立即拉新数据,不必等下次 30s 轮询 +- 服务端拒绝投票时(如其他设备已投过),本地立即回滚乐观更新 + 用 /api/me 重新对齐 +- 网络异常时回滚 + 报错,避免本地状态与服务端不一致 + +**根因(诊断详见 v0.3.2 后的对话)** +- `useRanking` 30s 轮询拉服务端票数,merge 时取 max(serverVotes, storeVotes) +- 用户投票后,storeVotes 立即 +1,但 serverVotes 还是 30s 前的旧值,且更大 +- max 永远取 server → 本地 +1 被压住 → 票数和排位最多延迟 30s 才更新 + +**技术修复** +- `useVoteAction` 加 `onVoteSuccess` 回调,服务端 200 后立即触发 +- `page.tsx` + `ranking/page.tsx` 把 `live.refresh` 传进去 +- 顺手把 fire-and-forget 改成 await 服务端,失败时 `rollbackVote` + `refetchMe(/api/me)` 跨设备对齐 +- store 新增 `rollbackVote(id)` + `hydrateFromServer(ids[])` 两个 action + +**体感对比** +| 场景 | 本地 dev(打公网 RDS) | 生产(同区 K3s + RDS) | +|------|---------------------|---------------------| +| 改前 | 0~30 秒后才看到票数 +1 | 同上 | +| 改后 | ~700-1000 ms(vote API 写完 + ranking refresh) | ~150 ms | + +**风险 / 已知问题** +- ArtistDetailContent 显示的 artist.votes 仍从 store 来,store 里这个字段是"本地用户投过几次"不是服务端真实票。详情页票数显示问题留下次单独修。 + +--- + ## v0.3.2 · 2026-05-15 · 修复:首页 Top12 出道位空着,排行榜却有数据 **Commit 信息** diff --git a/package.json b/package.json index 07a458a..3a445cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cyber-star", - "version": "0.3.2", + "version": "0.3.3", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/page.tsx b/src/app/page.tsx index cbf9a14..adacdbe 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,12 +19,14 @@ import type { Artist } from "@/types/artist"; export default function Home() { const storeArtists = useVoteStore((s) => s.artists); - const { target, remaining, totalQuota, openVote, closeVote, confirmVote } = - useVoteAction(); // 30s 轮询 /api/ranking 拿服务端真实票数 const live = useRanking({ pollInterval: 30_000 }); + // 投票成功后立即 refresh 排名,不等下次轮询(解决"票数 +1 / Top12 排位延迟"问题) + const { target, remaining, totalQuota, openVote, closeVote, confirmVote } = + useVoteAction({ onVoteSuccess: live.refresh }); + const [tagFilter, setTagFilter] = useState("all"); const [sortKey, setSortKey] = useState("votes"); const [filterStuck, setFilterStuck] = useState(false); diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx index c0a51f0..bd87cff 100644 --- a/src/app/ranking/page.tsx +++ b/src/app/ranking/page.tsx @@ -14,11 +14,13 @@ import type { Artist } from "@/types/artist"; export default function RankingPage() { const storeArtists = useVoteStore((s) => s.artists); - const { target, remaining, totalQuota, openVote, closeVote, confirmVote } = - useVoteAction(); const live = useRanking({ pollInterval: 30_000 }); + // 投票成功后立即 refresh 排名,不等下次轮询 + const { target, remaining, totalQuota, openVote, closeVote, confirmVote } = + useVoteAction({ onVoteSuccess: live.refresh }); + // 数据同步:本地乐观投票 + 服务端最新票数取 max(避免 API 落后覆盖本地新票, // 也避免本地缺其他用户的票数)。合并后按 votes desc + no asc 重新排序并赋 rank。 const sorted = useMemo(() => { diff --git a/src/hooks/useVoteAction.ts b/src/hooks/useVoteAction.ts index fc5c54e..0dc4067 100644 --- a/src/hooks/useVoteAction.ts +++ b/src/hooks/useVoteAction.ts @@ -26,6 +26,30 @@ interface UseVoteActionResult { confirmVote: (artist: Artist) => Promise; } +interface UseVoteActionOpts { + /** + * 投票成功且服务端 200 写入后回调 —— 调用方在此触发 useRanking.refresh(), + * 让 Top12 / 排行榜立即拉到新票数,而不是等 30s 下次轮询。 + * 不传则不做任何额外刷新(适合详情页等不显示排名的场景)。 + */ + onVoteSuccess?: () => void; +} + +/** 服务端拉一次 /api/me,把权威态灌进本地 store。用于跨设备状态对齐。 */ +async function refetchMe( + hydrateFromServer: (ids: string[]) => void, +): Promise { + try { + const res = await fetch("/api/me", { credentials: "include" }); + const data = await res.json(); + if (data?.ok && Array.isArray(data.data?.votedArtists)) { + hydrateFromServer(data.data.votedArtists as string[]); + } + } catch { + // 失败容忍 —— 下次页面交互或登录态变化会再同步 + } +} + /** * 投票交互统一入口。 * @@ -34,11 +58,16 @@ interface UseVoteActionResult { * - 未登录 → toast 提示并跳登录弹窗 * - 已投过该艺人 → toast 提示,不打开弹窗 * - 12 票已用完 → toast 提示,不打开弹窗 - * - 弹窗确认后:本地 store 立即记录 + 调用后端 API(fire-and-forget) + * - 弹窗确认后:乐观更新本地 + await 服务端写入 + * - 服务端拒绝 → 回滚本地 + 用 /api/me 重新对齐(跨设备真相源) + * - API 200 后触发 opts.onVoteSuccess() —— 让排名相关页面立即 refresh, + * 解决"票数 +1 / Top12 排位要等 30s"的体感问题 */ -export function useVoteAction(): UseVoteActionResult { +export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult { const { status } = useSession(); const recordVote = useVoteStore((s) => s.vote); + const rollbackVote = useVoteStore((s) => s.rollbackVote); + const hydrateFromServer = useVoteStore((s) => s.hydrateFromServer); const remaining = useVoteStore(selectRemaining); const votedArtists = useVoteStore((s) => s.votedArtists); const openLogin = useLoginModalStore((s) => s.show); @@ -69,7 +98,7 @@ export function useVoteAction(): UseVoteActionResult { const confirmVote = useCallback( async (artist: Artist) => { - // 1. 本地 store 记录(包含已投/已满校验) + // 1. 乐观更新本地(含已投/已满兜底校验) const result = recordVote(artist.id); if (!result.ok) { if (result.reason === "already") { @@ -81,30 +110,67 @@ export function useVoteAction(): UseVoteActionResult { return; } - // 投票成功:计算投票后状态,判断是否是最后一票 + // 乐观成功提示;若服务端拒绝再 dismiss + 报错 const remainingAfter = remaining - 1; - if (remainingAfter === 0) { - toast.success(`完成!你的 12 票已全部投出 ✦`, { duration: 4000 }); - } else { - toast.success(`已为 ${artist.name} 投票 · 剩余 ${remainingAfter} 票`); - } + const successMsg = + remainingAfter === 0 + ? `完成!你的 12 票已全部投出 ✦` + : `已为 ${artist.name} 投票 · 剩余 ${remainingAfter} 票`; + const successToastId = toast.success(successMsg, { + duration: remainingAfter === 0 ? 4000 : 2800, + }); setTarget(null); - // 2. 后台 fire-and-forget 调用真实 API(5 秒超时,失败静默忽略) - // 注意:旧 API 仍接收 count 参数,这里固定传 1。后端逻辑 unique 约束 - // 等后续提交单独迁移,现阶段前端 store 已保证不会重投。 + // 2. await 服务端真实写入。失败 → 回滚 + 用 /api/me 对齐 const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 5000); - fetch("/api/vote", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ artistId: artist.id, count: 1 }), - signal: ctrl.signal, - }) - .catch(() => {}) - .finally(() => clearTimeout(timer)); + const timer = setTimeout(() => ctrl.abort(), 8000); + try { + const res = await fetch("/api/vote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ artistId: artist.id, count: 1 }), + signal: ctrl.signal, + credentials: "include", + }); + const data = await res.json().catch(() => null); + + if (res.ok && data?.ok) { + // API 200 → 通知调用方(如首页)立即 refresh 排名,不等 30s 轮询 + opts.onVoteSuccess?.(); + return; + } + + // 服务端拒绝 → 回滚乐观更新 + rollbackVote(artist.id); + toast.dismiss(successToastId); + + const code: string | undefined = data?.error?.code; + if (code === "ALREADY_VOTED") { + toast.error("你已在其他设备为该艺人投过票"); + } else if (code === "QUOTA_EXHAUSTED") { + toast.error("你的 12 票已在其他设备投完"); + } else if (code === "UNAUTHORIZED") { + toast.error("登录已失效,请重新登录"); + } else if (code === "ACTIVITY_OFF") { + toast.error("投票活动暂未开放"); + } else if (code === "RATE_LIMITED") { + toast.error("操作太快了,请稍后再试"); + } else { + toast.error(data?.error?.message ?? "投票失败,请重试"); + } + + // 跨设备状态对齐:服务端永远是真相源 + await refetchMe(hydrateFromServer); + } catch { + // 网络错误 / 超时 → 回滚 + 不强拉 /api/me(网络问题大概率也拉不到) + rollbackVote(artist.id); + toast.dismiss(successToastId); + toast.error("网络异常,投票未生效"); + } finally { + clearTimeout(timer); + } }, - [recordVote, remaining], + [recordVote, rollbackVote, hydrateFromServer, remaining, opts], ); return { diff --git a/src/lib/store.ts b/src/lib/store.ts index abc55f5..f866952 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -30,7 +30,14 @@ interface VoteStore { * - 成功 → 返回 { ok: true } */ vote: (artistId: string) => { ok: boolean; reason?: "already" | "exhausted" }; - /** 重置(开发时用 / 测试用) */ + /** + * 服务端权威态覆盖本地态:登录后从 /api/me 拿到的 votedArtists 直接灌进来, + * 跨设备/清缓存的关键 —— 本地 localStorage 不再是唯一真相源。 + */ + hydrateFromServer: (votedArtists: string[]) => void; + /** 服务端拒绝时回滚单次投票(乐观更新的兜底) */ + rollbackVote: (artistId: string) => void; + /** 重置(开发时用 / 测试用 / 登出时清理) */ reset: () => void; } @@ -65,6 +72,37 @@ export const useVoteStore = create()( }); return { ok: true }; }, + hydrateFromServer: (votedArtists) => { + // 用服务端返回的 votedArtists 重建 artists 票数(回放 mock baseline) + const counts = new Map(); + for (const id of votedArtists) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + const rebuilt = INITIAL_ARTISTS.map((a) => ({ + ...a, + votes: a.votes + (counts.get(a.id) ?? 0), + })); + set({ + artists: rank(rebuilt), + votedArtists, + }); + }, + rollbackVote: (artistId) => { + const state = get(); + const idx = state.votedArtists.lastIndexOf(artistId); + if (idx === -1) return; + const nextVoted = [ + ...state.votedArtists.slice(0, idx), + ...state.votedArtists.slice(idx + 1), + ]; + const updated = state.artists.map((a) => + a.id === artistId ? { ...a, votes: Math.max(0, a.votes - 1) } : a, + ); + set({ + artists: rank(updated), + votedArtists: nextVoted, + }); + }, reset: () => set({ artists: INITIAL_ARTISTS, @@ -75,7 +113,8 @@ export const useVoteStore = create()( name: "cyber-star-vote", // 仅持久化 votedArtists —— artists 票数/排名是派生数据, // 刷新后重新从初始数据 + votedArtists 重建。 - // 注意:当前 mock 阶段 artists 只反映本地投票,不同步服务端 —— 等后端接入再调整。 + // localStorage 仅作本设备缓存,加速首屏渲染;真相源是 /api/me + // —— useSyncMe 会在登录/切换用户后用服务端数据覆盖本地。 partialize: (state) => ({ votedArtists: state.votedArtists }), // rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致 onRehydrateStorage: () => (state) => {