- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import {
|
||
type ChallengeDetailDto,
|
||
type ChallengeListItemDto,
|
||
type ChallengeProgressDto,
|
||
type ChallengeStatus,
|
||
type RankingItemDto,
|
||
getChallengeDetail,
|
||
getChallengeRankings,
|
||
joinChallenge as joinChallengeApi,
|
||
leaveChallenge as leaveChallengeApi,
|
||
listChallenges,
|
||
reportChallengeProgress as reportChallengeProgressApi,
|
||
} from '@/services/challengesApi';
|
||
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
||
import type { RootState } from './index';
|
||
|
||
type AsyncStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
|
||
|
||
export type ChallengeProgress = ChallengeProgressDto;
|
||
export type RankingItem = RankingItemDto;
|
||
export type ChallengeSummary = ChallengeListItemDto;
|
||
export type ChallengeDetail = ChallengeDetailDto;
|
||
export type { ChallengeStatus };
|
||
export type ChallengeEntity = ChallengeSummary & {
|
||
summary?: string;
|
||
rankings?: RankingItem[];
|
||
userRank?: number;
|
||
};
|
||
|
||
type ChallengeRankingList = {
|
||
items: RankingItem[];
|
||
total: number;
|
||
page: number;
|
||
pageSize: number;
|
||
hasMore: boolean;
|
||
};
|
||
|
||
type ChallengesState = {
|
||
entities: Record<string, ChallengeEntity>;
|
||
order: string[];
|
||
listStatus: AsyncStatus;
|
||
listError?: string;
|
||
detailStatus: Record<string, AsyncStatus>;
|
||
detailError: Record<string, string | undefined>;
|
||
joinStatus: Record<string, AsyncStatus>;
|
||
joinError: Record<string, string | undefined>;
|
||
leaveStatus: Record<string, AsyncStatus>;
|
||
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 = {
|
||
entities: {},
|
||
order: [],
|
||
listStatus: 'idle',
|
||
listError: undefined,
|
||
detailStatus: {},
|
||
detailError: {},
|
||
joinStatus: {},
|
||
joinError: {},
|
||
leaveStatus: {},
|
||
leaveError: {},
|
||
progressStatus: {},
|
||
progressError: {},
|
||
rankingList: {},
|
||
rankingStatus: {},
|
||
rankingLoadMoreStatus: {},
|
||
rankingError: {},
|
||
};
|
||
|
||
const toErrorMessage = (error: unknown): string => {
|
||
if (typeof error === 'string') {
|
||
return error;
|
||
}
|
||
if (error instanceof Error && error.message) {
|
||
return error.message;
|
||
}
|
||
return '请求失败,请稍后再试';
|
||
};
|
||
|
||
export const fetchChallenges = createAsyncThunk<ChallengeSummary[], void, { rejectValue: string }>(
|
||
'challenges/fetchAll',
|
||
async (_, { rejectWithValue }) => {
|
||
try {
|
||
return await listChallenges();
|
||
} catch (error) {
|
||
return rejectWithValue(toErrorMessage(error));
|
||
}
|
||
}
|
||
);
|
||
|
||
export const fetchChallengeDetail = createAsyncThunk<ChallengeDetail, string, { rejectValue: string }>(
|
||
'challenges/fetchDetail',
|
||
async (id, { rejectWithValue }) => {
|
||
try {
|
||
const ret = await getChallengeDetail(id);
|
||
|
||
return ret;
|
||
} catch (error) {
|
||
console.log('######', error);
|
||
return rejectWithValue(toErrorMessage(error));
|
||
}
|
||
}
|
||
);
|
||
|
||
export const joinChallenge = createAsyncThunk<{ id: string; progress: ChallengeProgress }, string, { rejectValue: string }>(
|
||
'challenges/join',
|
||
async (id, { rejectWithValue }) => {
|
||
try {
|
||
const progress = await joinChallengeApi(id);
|
||
return { id, progress };
|
||
} catch (error) {
|
||
return rejectWithValue(toErrorMessage(error));
|
||
}
|
||
}
|
||
);
|
||
|
||
export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectValue: string }>(
|
||
'challenges/leave',
|
||
async (id, { rejectWithValue }) => {
|
||
try {
|
||
await leaveChallengeApi(id);
|
||
return { id };
|
||
} catch (error) {
|
||
return rejectWithValue(toErrorMessage(error));
|
||
}
|
||
}
|
||
);
|
||
|
||
export const reportChallengeProgress = createAsyncThunk<
|
||
{ id: string; progress: ChallengeProgress },
|
||
{ id: string; value?: number },
|
||
{ rejectValue: string }
|
||
>('challenges/reportProgress', async ({ id, value }, { rejectWithValue }) => {
|
||
try {
|
||
const progress = await reportChallengeProgressApi(id, value);
|
||
return { id, progress };
|
||
} catch (error) {
|
||
return rejectWithValue(toErrorMessage(error));
|
||
}
|
||
});
|
||
|
||
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,
|
||
reducers: {},
|
||
extraReducers: (builder) => {
|
||
builder
|
||
.addCase(fetchChallenges.pending, (state) => {
|
||
state.listStatus = 'loading';
|
||
state.listError = undefined;
|
||
})
|
||
.addCase(fetchChallenges.fulfilled, (state, action) => {
|
||
state.listStatus = 'succeeded';
|
||
state.listError = undefined;
|
||
const ids = new Set<string>();
|
||
action.payload.forEach((challenge) => {
|
||
ids.add(challenge.id);
|
||
const existing = state.entities[challenge.id];
|
||
if (existing) {
|
||
Object.assign(existing, challenge);
|
||
} else {
|
||
state.entities[challenge.id] = { ...challenge };
|
||
}
|
||
});
|
||
Object.keys(state.entities).forEach((id) => {
|
||
if (!ids.has(id)) {
|
||
delete state.entities[id];
|
||
delete state.detailStatus[id];
|
||
delete state.detailError[id];
|
||
delete state.joinStatus[id];
|
||
delete state.joinError[id];
|
||
delete state.leaveStatus[id];
|
||
delete state.leaveError[id];
|
||
delete state.progressStatus[id];
|
||
delete state.progressError[id];
|
||
}
|
||
});
|
||
state.order = action.payload.map((item) => item.id);
|
||
})
|
||
.addCase(fetchChallenges.rejected, (state, action) => {
|
||
state.listStatus = 'failed';
|
||
state.listError = action.payload ?? toErrorMessage(action.error);
|
||
})
|
||
.addCase(fetchChallengeDetail.pending, (state, action) => {
|
||
const id = action.meta.arg;
|
||
state.detailStatus[id] = 'loading';
|
||
state.detailError[id] = undefined;
|
||
})
|
||
.addCase(fetchChallengeDetail.fulfilled, (state, action) => {
|
||
const detail = action.payload;
|
||
state.detailStatus[detail.id] = 'succeeded';
|
||
state.detailError[detail.id] = undefined;
|
||
const existing = state.entities[detail.id];
|
||
if (existing) {
|
||
Object.assign(existing, detail);
|
||
} else {
|
||
state.entities[detail.id] = { ...detail };
|
||
}
|
||
})
|
||
.addCase(fetchChallengeDetail.rejected, (state, action) => {
|
||
const id = action.meta.arg;
|
||
state.detailStatus[id] = 'failed';
|
||
state.detailError[id] = action.payload ?? toErrorMessage(action.error);
|
||
})
|
||
.addCase(joinChallenge.pending, (state, action) => {
|
||
const id = action.meta.arg;
|
||
state.joinStatus[id] = 'loading';
|
||
state.joinError[id] = undefined;
|
||
})
|
||
.addCase(joinChallenge.fulfilled, (state, action) => {
|
||
const { id, progress } = action.payload;
|
||
state.joinStatus[id] = 'succeeded';
|
||
state.joinError[id] = undefined;
|
||
const entity = state.entities[id];
|
||
if (entity) {
|
||
entity.isJoined = true;
|
||
entity.progress = progress;
|
||
}
|
||
})
|
||
.addCase(joinChallenge.rejected, (state, action) => {
|
||
const id = action.meta.arg;
|
||
state.joinStatus[id] = 'failed';
|
||
state.joinError[id] = action.payload ?? toErrorMessage(action.error);
|
||
})
|
||
.addCase(leaveChallenge.pending, (state, action) => {
|
||
const id = action.meta.arg;
|
||
state.leaveStatus[id] = 'loading';
|
||
state.leaveError[id] = undefined;
|
||
})
|
||
.addCase(leaveChallenge.fulfilled, (state, action) => {
|
||
const { id } = action.payload;
|
||
state.leaveStatus[id] = 'succeeded';
|
||
state.leaveError[id] = undefined;
|
||
const entity = state.entities[id];
|
||
if (entity) {
|
||
entity.isJoined = false;
|
||
delete entity.progress;
|
||
}
|
||
})
|
||
.addCase(leaveChallenge.rejected, (state, action) => {
|
||
const id = action.meta.arg;
|
||
state.leaveStatus[id] = 'failed';
|
||
state.leaveError[id] = action.payload ?? toErrorMessage(action.error);
|
||
})
|
||
.addCase(reportChallengeProgress.pending, (state, action) => {
|
||
const id = action.meta.arg.id;
|
||
state.progressStatus[id] = 'loading';
|
||
state.progressError[id] = undefined;
|
||
})
|
||
.addCase(reportChallengeProgress.fulfilled, (state, action) => {
|
||
const { id, progress } = action.payload;
|
||
state.progressStatus[id] = 'succeeded';
|
||
state.progressError[id] = undefined;
|
||
const entity = state.entities[id];
|
||
if (entity) {
|
||
entity.progress = progress;
|
||
}
|
||
})
|
||
.addCase(reportChallengeProgress.rejected, (state, action) => {
|
||
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;
|
||
}
|
||
});
|
||
},
|
||
});
|
||
|
||
export default challengesSlice.reducer;
|
||
|
||
const selectChallengesState = (state: RootState) => state.challenges;
|
||
|
||
export const selectChallengesListStatus = createSelector(
|
||
[selectChallengesState],
|
||
(state) => state.listStatus
|
||
);
|
||
|
||
export const selectChallengesListError = createSelector(
|
||
[selectChallengesState],
|
||
(state) => state.listError
|
||
);
|
||
|
||
export const selectChallengeEntities = createSelector(
|
||
[selectChallengesState],
|
||
(state) => state.entities
|
||
);
|
||
|
||
export const selectChallengeOrder = createSelector(
|
||
[selectChallengesState],
|
||
(state) => state.order
|
||
);
|
||
|
||
export const selectChallengeList = createSelector(
|
||
[selectChallengeEntities, selectChallengeOrder],
|
||
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
|
||
);
|
||
|
||
const formatNumberWithSeparator = (value: number): string =>
|
||
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
|
||
const formatMonthDay = (input: string | undefined): string | undefined => {
|
||
if (!input) return undefined;
|
||
const date = new Date(input);
|
||
if (Number.isNaN(date.getTime())) return undefined;
|
||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||
};
|
||
|
||
const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
||
console.log('!!!!!!', challenge);
|
||
|
||
const startLabel = formatMonthDay(challenge.startAt);
|
||
const endLabel = formatMonthDay(challenge.endAt);
|
||
if (startLabel && endLabel) {
|
||
return `${startLabel} - ${endLabel}`;
|
||
}
|
||
return challenge.periodLabel ?? challenge.durationLabel;
|
||
};
|
||
|
||
export type ChallengeCardViewModel = {
|
||
id: string;
|
||
title: string;
|
||
image: string;
|
||
dateRange: string;
|
||
participantsLabel: string;
|
||
status: ChallengeStatus;
|
||
isJoined: boolean;
|
||
endAt?: string;
|
||
periodLabel?: string;
|
||
durationLabel: string;
|
||
requirementLabel: string;
|
||
highlightTitle: string;
|
||
highlightSubtitle: string;
|
||
ctaLabel: string;
|
||
progress?: ChallengeProgress;
|
||
avatars: string[];
|
||
};
|
||
|
||
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
||
challenges.map<ChallengeCardViewModel>((challenge) => ({
|
||
id: challenge.id,
|
||
title: challenge.title,
|
||
image: challenge.image,
|
||
dateRange: buildDateRangeLabel(challenge),
|
||
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
|
||
status: challenge.status,
|
||
isJoined: challenge.isJoined,
|
||
endAt: challenge.endAt,
|
||
periodLabel: challenge.periodLabel,
|
||
durationLabel: challenge.durationLabel,
|
||
requirementLabel: challenge.requirementLabel,
|
||
highlightTitle: challenge.highlightTitle,
|
||
highlightSubtitle: challenge.highlightSubtitle,
|
||
ctaLabel: challenge.ctaLabel,
|
||
progress: challenge.progress,
|
||
avatars: [],
|
||
}))
|
||
);
|
||
|
||
export const selectChallengeById = (id: string) =>
|
||
createSelector([selectChallengeEntities], (entities) => entities[id]);
|
||
|
||
export const selectChallengeDetailStatus = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.detailStatus[id] ?? 'idle');
|
||
|
||
export const selectChallengeDetailError = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.detailError[id]);
|
||
|
||
export const selectJoinStatus = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.joinStatus[id] ?? 'idle');
|
||
|
||
export const selectJoinError = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.joinError[id]);
|
||
|
||
export const selectLeaveStatus = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.leaveStatus[id] ?? 'idle');
|
||
|
||
export const selectLeaveError = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.leaveError[id]);
|
||
|
||
export const selectProgressStatus = (id: string) =>
|
||
createSelector([selectChallengesState], (state) => state.progressStatus[id] ?? 'idle');
|
||
|
||
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]);
|