fix(vote): 投票后立即 refresh ranking,不等 30s 轮询
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m48s
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:
parent
51009616a1
commit
5c009f38cd
32
CHANGELOG.md
32
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 信息**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cyber-star",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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[]>(() => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user