feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
type ChallengeStatus,
|
||||
type RankingItemDto,
|
||||
getChallengeDetail,
|
||||
getChallengeRankings,
|
||||
joinChallenge as joinChallengeApi,
|
||||
leaveChallenge as leaveChallengeApi,
|
||||
listChallenges,
|
||||
@@ -26,6 +27,14 @@ export type ChallengeEntity = ChallengeSummary & {
|
||||
userRank?: number;
|
||||
};
|
||||
|
||||
type ChallengeRankingList = {
|
||||
items: RankingItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type ChallengesState = {
|
||||
entities: Record<string, ChallengeEntity>;
|
||||
order: string[];
|
||||
@@ -39,6 +48,10 @@ type ChallengesState = {
|
||||
leaveError: Record<string, string | undefined>;
|
||||
progressStatus: Record<string, AsyncStatus>;
|
||||
progressError: Record<string, string | undefined>;
|
||||
rankingList: Record<string, ChallengeRankingList | undefined>;
|
||||
rankingStatus: Record<string, AsyncStatus>;
|
||||
rankingLoadMoreStatus: Record<string, AsyncStatus>;
|
||||
rankingError: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const initialState: ChallengesState = {
|
||||
@@ -54,6 +67,10 @@ const initialState: ChallengesState = {
|
||||
leaveError: {},
|
||||
progressStatus: {},
|
||||
progressError: {},
|
||||
rankingList: {},
|
||||
rankingStatus: {},
|
||||
rankingLoadMoreStatus: {},
|
||||
rankingError: {},
|
||||
};
|
||||
|
||||
const toErrorMessage = (error: unknown): string => {
|
||||
@@ -128,6 +145,19 @@ export const reportChallengeProgress = createAsyncThunk<
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchChallengeRankings = createAsyncThunk<
|
||||
{ id: string; total: number; page: number; pageSize: number; items: RankingItem[] },
|
||||
{ id: string; page?: number; pageSize?: number },
|
||||
{ rejectValue: string }
|
||||
>('challenges/fetchRankings', async ({ id, page = 1, pageSize = 20 }, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await getChallengeRankings(id, { page, pageSize });
|
||||
return { id, ...data };
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
|
||||
const challengesSlice = createSlice({
|
||||
name: 'challenges',
|
||||
initialState,
|
||||
@@ -249,6 +279,49 @@ const challengesSlice = createSlice({
|
||||
const id = action.meta.arg.id;
|
||||
state.progressStatus[id] = 'failed';
|
||||
state.progressError[id] = action.payload ?? toErrorMessage(action.error);
|
||||
})
|
||||
.addCase(fetchChallengeRankings.pending, (state, action) => {
|
||||
const { id, page = 1 } = action.meta.arg;
|
||||
if (page <= 1) {
|
||||
state.rankingStatus[id] = 'loading';
|
||||
state.rankingError[id] = undefined;
|
||||
state.rankingLoadMoreStatus[id] = 'idle';
|
||||
} else {
|
||||
state.rankingLoadMoreStatus[id] = 'loading';
|
||||
}
|
||||
})
|
||||
.addCase(fetchChallengeRankings.fulfilled, (state, action) => {
|
||||
const { id, items, page, pageSize, total } = action.payload;
|
||||
const existing = state.rankingList[id];
|
||||
let merged: RankingItem[];
|
||||
if (!existing || page <= 1) {
|
||||
merged = [...items];
|
||||
} else {
|
||||
const map = new Map(existing.items.map((item) => [item.id, item] as const));
|
||||
items.forEach((item) => {
|
||||
map.set(item.id, item);
|
||||
});
|
||||
merged = Array.from(map.values());
|
||||
}
|
||||
const hasMore = merged.length < total;
|
||||
state.rankingList[id] = { items: merged, total, page, pageSize, hasMore };
|
||||
if (page <= 1) {
|
||||
state.rankingStatus[id] = 'succeeded';
|
||||
state.rankingError[id] = undefined;
|
||||
} else {
|
||||
state.rankingLoadMoreStatus[id] = 'succeeded';
|
||||
}
|
||||
})
|
||||
.addCase(fetchChallengeRankings.rejected, (state, action) => {
|
||||
const { id, page = 1 } = action.meta.arg;
|
||||
const message = action.payload ?? toErrorMessage(action.error);
|
||||
if (page <= 1) {
|
||||
state.rankingStatus[id] = 'failed';
|
||||
state.rankingError[id] = message;
|
||||
} else {
|
||||
state.rankingLoadMoreStatus[id] = 'failed';
|
||||
state.rankingError[id] = message;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -369,3 +442,15 @@ export const selectProgressStatus = (id: string) =>
|
||||
|
||||
export const selectProgressError = (id: string) =>
|
||||
createSelector([selectChallengesState], (state) => state.progressError[id]);
|
||||
|
||||
export const selectChallengeRankingList = (id: string) =>
|
||||
createSelector([selectChallengesState], (state) => state.rankingList[id]);
|
||||
|
||||
export const selectChallengeRankingStatus = (id: string) =>
|
||||
createSelector([selectChallengesState], (state) => state.rankingStatus[id] ?? 'idle');
|
||||
|
||||
export const selectChallengeRankingLoadMoreStatus = (id: string) =>
|
||||
createSelector([selectChallengesState], (state) => state.rankingLoadMoreStatus[id] ?? 'idle');
|
||||
|
||||
export const selectChallengeRankingError = (id: string) =>
|
||||
createSelector([selectChallengesState], (state) => state.rankingError[id]);
|
||||
|
||||
Reference in New Issue
Block a user