import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit'; import { type ChallengeDetailDto, type ChallengeListItemDto, type ChallengeProgressDto, type ChallengeStatus, type RankingItemDto, getChallengeDetail, joinChallenge as joinChallengeApi, leaveChallenge as leaveChallengeApi, listChallenges, reportChallengeProgress as reportChallengeProgressApi, } from '@/services/challengesApi'; 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 ChallengesState = { entities: Record; order: string[]; listStatus: AsyncStatus; listError?: string; detailStatus: Record; detailError: Record; joinStatus: Record; joinError: Record; leaveStatus: Record; leaveError: Record; progressStatus: Record; progressError: Record; }; const initialState: ChallengesState = { entities: {}, order: [], listStatus: 'idle', listError: undefined, detailStatus: {}, detailError: {}, joinStatus: {}, joinError: {}, leaveStatus: {}, leaveError: {}, progressStatus: {}, progressError: {}, }; 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( 'challenges/fetchAll', async (_, { rejectWithValue }) => { try { return await listChallenges(); } catch (error) { return rejectWithValue(toErrorMessage(error)); } } ); export const fetchChallengeDetail = createAsyncThunk( 'challenges/fetchDetail', async (id, { rejectWithValue }) => { try { return await getChallengeDetail(id); } catch (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; increment?: number }, { rejectValue: string } >('challenges/reportProgress', async ({ id, increment }, { rejectWithValue }) => { try { const progress = await reportChallengeProgressApi(id, increment); return { id, progress }; } 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(); 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); }); }, }); 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 => { 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; periodLabel?: string; durationLabel: string; requirementLabel: string; highlightTitle: string; highlightSubtitle: string; ctaLabel: string; progress?: ChallengeProgress; avatars: string[]; }; export const selectChallengeCards = createSelector([selectChallengeList], (challenges) => challenges.map((challenge) => ({ id: challenge.id, title: challenge.title, image: challenge.image, dateRange: buildDateRangeLabel(challenge), participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`, status: challenge.status, isJoined: challenge.isJoined, 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]);