fix(vote): 投票后立即 refresh ranking,不等 30s 轮询
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m48s

- useVoteAction 加 onVoteSuccess 回调,服务端 200 立即触发
- page.tsx + ranking/page.tsx 传 live.refresh
- 顺手把 fire-and-forget 改成 await + 失败回滚 + /api/me 跨设备对齐
- store 新增 rollbackVote + hydrateFromServer 两个 action

体感:本地 30s → 700ms,生产 30s → 150ms

bump to v0.3.3

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iye 2026-05-18 14:25:40 +08:00
parent 51009616a1
commit 5c009f38cd
6 changed files with 170 additions and 29 deletions

View File

@ -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 信息**

View File

@ -1,6 +1,6 @@
{
"name": "cyber-star",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"scripts": {
"dev": "next dev",

View File

@ -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<TagFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("votes");
const [filterStuck, setFilterStuck] = useState(false);

View File

@ -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<Artist[]>(() => {

View File

@ -26,6 +26,30 @@ interface UseVoteActionResult {
confirmVote: (artist: Artist) => Promise<void>;
}
interface UseVoteActionOpts {
/**
* 200 useRanking.refresh(),
* Top12 / , 30s
* ()
*/
onVoteSuccess?: () => void;
}
/** 服务端拉一次 /api/me,把权威态灌进本地 store。用于跨设备状态对齐。 */
async function refetchMe(
hydrateFromServer: (ids: string[]) => void,
): Promise<void> {
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 {

View File

@ -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<VoteStore>()(
});
return { ok: true };
},
hydrateFromServer: (votedArtists) => {
// 用服务端返回的 votedArtists 重建 artists 票数(回放 mock baseline)
const counts = new Map<string, number>();
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<VoteStore>()(
name: "cyber-star-vote",
// 仅持久化 votedArtists —— artists 票数/排名是派生数据,
// 刷新后重新从初始数据 + votedArtists 重建。
// 注意:当前 mock 阶段 artists 只反映本地投票,不同步服务端 —— 等后端接入再调整。
// localStorage 仅作本设备缓存,加速首屏渲染;真相源是 /api/me
// —— useSyncMe 会在登录/切换用户后用服务端数据覆盖本地。
partialize: (state) => ({ votedArtists: state.votedArtists }),
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
onRehydrateStorage: () => (state) => {