Files
digital-pilates/store/challengesSlice.ts
richarjiang b80af23f4f feat(challenges): 优化挑战列表与详情页交互体验
- 替换 Image 为 expo-image 并启用缓存策略
- 调整礼物按钮尺寸与图标大小
- 加入挑战失败时弹出 Toast 提示
- 统一异步流程并移除冗余状态监听
- 清理调试日志与多余空行
2025-09-29 09:59:47 +08:00

370 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 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>;
};
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<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; 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<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);
});
},
});
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;
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,
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]);