Files
digital-pilates/store/challengesSlice.ts
richarjiang 3e6f55d804 feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度
- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度
- FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度
- 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动
- 移除 StressMeter 调试日志与 challengesSlice 多余打印
2025-09-30 14:37:15 +08:00

455 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 => {
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]);