Files
digital-pilates/store/challengesSlice.ts
richarjiang 970a4b8568 feat(challenges): 新增 ChallengeProgressCard 组件并接入喝水挑战进度上报
- 抽离进度卡片为独立组件,支持主题色自定义与复用
- 挑战列表页顶部展示进行中的挑战进度
- 喝水记录自动上报至关联的水挑战
- 移除旧版 challengeSlice 与冗余进度样式
- 统一使用 value 字段上报进度,兼容多类型挑战
2025-09-29 15:14:59 +08:00

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