feat(challenges): 接入真实接口并完善挑战列表与详情状态管理
- 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口 - 重构 challengesSlice,使用 createAsyncThunk 管理异步状态 - 列表页支持加载、空态、错误重试及状态标签 - 详情页支持进度展示、打卡、退出及错误提示 - 统一卡片与详情数据模型,支持动态状态更新
This commit is contained in:
@@ -1,169 +1,364 @@
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
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';
|
||||
|
||||
export type ChallengeDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
participantsCount: number;
|
||||
participantsUnit: string;
|
||||
image: string;
|
||||
avatars: string[];
|
||||
};
|
||||
type AsyncStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
|
||||
export type ChallengeViewModel = ChallengeDefinition & {
|
||||
dateRange: string;
|
||||
participantsLabel: string;
|
||||
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, ChallengeDefinition>;
|
||||
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 initialChallenges: ChallengeDefinition[] = [
|
||||
{
|
||||
id: 'joyful-dog-run',
|
||||
title: '遛狗跑步,欢乐一路',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2024-09-30',
|
||||
participantsCount: 6364,
|
||||
participantsUnit: '跑者',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1525253086316-d0c936c814f8?auto=format&fit=crop&w=1200&q=80',
|
||||
avatars: [
|
||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1544723795-3fbce826f51f?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1502823403499-6ccfcf4fb453?auto=format&fit=crop&w=200&q=80',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'penguin-swim',
|
||||
title: '企鹅宝宝的游泳预备班',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2024-09-30',
|
||||
participantsCount: 3334,
|
||||
participantsUnit: '游泳者',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1531297484001-80022131f5a1?auto=format&fit=crop&w=1200&q=80',
|
||||
avatars: [
|
||||
'https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1530268729831-4b0b9e170218?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=200&q=80',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hydration-hippo',
|
||||
title: '学河马饮,做补水人',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2024-09-30',
|
||||
participantsCount: 9009,
|
||||
participantsUnit: '饮水者',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1481931098730-318b6f776db0?auto=format&fit=crop&w=1200&q=80',
|
||||
avatars: [
|
||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1544723660-4bfa6584218e?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1544723795-3fbfb7c6a9f1?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=200&q=80',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'autumn-cycling',
|
||||
title: '炎夏渐散,踏板骑秋',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2024-09-30',
|
||||
participantsCount: 4617,
|
||||
participantsUnit: '骑行者',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1509395176047-4a66953fd231?auto=format&fit=crop&w=1200&q=80',
|
||||
avatars: [
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'falcon-core',
|
||||
title: '燃卡加练甄秋腰',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2024-09-30',
|
||||
participantsCount: 11995,
|
||||
participantsUnit: '健身爱好者',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1494871262121-6adf66e90adf?auto=format&fit=crop&w=1200&q=80',
|
||||
avatars: [
|
||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=200&q=80',
|
||||
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: ChallengesState = {
|
||||
entities: initialChallenges.reduce<Record<string, ChallengeDefinition>>((acc, challenge) => {
|
||||
acc[challenge.id] = challenge;
|
||||
return acc;
|
||||
}, {}),
|
||||
order: initialChallenges.map((challenge) => challenge.id),
|
||||
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 {
|
||||
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<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 selectChallengeEntities = createSelector([selectChallengesState], (state) => state.entities);
|
||||
export const selectChallengesListStatus = createSelector(
|
||||
[selectChallengesState],
|
||||
(state) => state.listStatus
|
||||
);
|
||||
|
||||
export const selectChallengeOrder = createSelector([selectChallengesState], (state) => state.order);
|
||||
export const selectChallengesListError = createSelector(
|
||||
[selectChallengesState],
|
||||
(state) => state.listError
|
||||
);
|
||||
|
||||
const formatNumberWithSeparator = (value: number): string => value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
export const selectChallengeEntities = createSelector(
|
||||
[selectChallengesState],
|
||||
(state) => state.entities
|
||||
);
|
||||
|
||||
const formatDateLabel = (value: string): string => {
|
||||
const [year, month, day] = value.split('-');
|
||||
if (!month || !day) {
|
||||
return value;
|
||||
}
|
||||
const monthNumber = parseInt(month, 10);
|
||||
const dayNumber = parseInt(day, 10);
|
||||
const paddedDay = Number.isNaN(dayNumber) ? day : dayNumber.toString().padStart(2, '0');
|
||||
if (Number.isNaN(monthNumber)) {
|
||||
return value;
|
||||
}
|
||||
return `${monthNumber}月${paddedDay}日`;
|
||||
};
|
||||
|
||||
const toViewModel = (challenge: ChallengeDefinition): ChallengeViewModel => ({
|
||||
...challenge,
|
||||
dateRange: `${formatDateLabel(challenge.startDate)} - ${formatDateLabel(challenge.endDate)}`,
|
||||
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} ${challenge.participantsUnit}`,
|
||||
});
|
||||
export const selectChallengeOrder = createSelector(
|
||||
[selectChallengesState],
|
||||
(state) => state.order
|
||||
);
|
||||
|
||||
export const selectChallengeList = createSelector(
|
||||
[selectChallengeEntities, selectChallengeOrder],
|
||||
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeDefinition[],
|
||||
(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) => toViewModel(challenge))
|
||||
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 selectChallengeViewById = (id: string) =>
|
||||
createSelector([selectChallengeEntities], (entities) => {
|
||||
const challenge = entities[id];
|
||||
return challenge ? toViewModel(challenge) : undefined;
|
||||
});
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user