Files
digital-pilates/store/challengesSlice.ts
richarjiang 39671ed70f feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等
- 实现挑战邀请码系统,支持通过邀请码加入自定义挑战
- 完善挑战详情页面的多语言翻译支持
- 优化用户认证状态检查逻辑,使用token作为主要判断依据
- 添加阿里字体文件支持,提升UI显示效果
- 改进确认弹窗组件,支持Liquid Glass效果和自定义内容
- 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
2025-11-26 16:39:01 +08:00

679 lines
24 KiB
TypeScript

import dayjs from 'dayjs';
import { appStoreReviewService } from '@/services/appStoreReview';
import {
type ChallengeDetailDto,
type ChallengeListItemDto,
type ChallengeProgressDto,
ChallengeSource,
ChallengeState,
type ChallengeStatus,
type CreateCustomChallengePayload,
type RankingItemDto,
createCustomChallenge,
getChallengeByShareCode,
getChallengeDetail,
getChallengeRankings,
joinChallenge as joinChallengeApi,
joinChallengeByCode as joinChallengeByCodeApi,
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 { ChallengeSource, ChallengeState, ChallengeStatus };
export type ChallengeEntity = ChallengeSummary & {
summary?: string | null;
rankings?: RankingItem[];
userRank?: number;
};
type ChallengeRankingList = {
items: RankingItem[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
};
type ChallengesState = {
entities: Record<string, ChallengeEntity>;
orderedIds: 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>;
createStatus: AsyncStatus;
createError?: string;
joinByCodeStatus: AsyncStatus;
joinByCodeError?: string;
};
const initialState: ChallengesState = {
entities: {},
orderedIds: [],
listStatus: 'idle',
listError: undefined,
detailStatus: {},
detailError: {},
joinStatus: {},
joinError: {},
leaveStatus: {},
leaveError: {},
progressStatus: {},
progressError: {},
rankingList: {},
rankingStatus: {},
rankingLoadMoreStatus: {},
rankingError: {},
createStatus: 'idle',
createError: undefined,
joinByCodeStatus: 'idle',
joinByCodeError: undefined,
};
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);
// 用户成功加入挑战后,尝试请求应用评分
// 使用 setTimeout 延迟执行,避免阻塞主流程
setTimeout(() => {
appStoreReviewService.requestReview().catch((error) => {
console.error('应用评分请求失败:', error);
});
}, 1000);
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));
}
});
export const createCustomChallengeThunk = createAsyncThunk<
ChallengeDetail,
CreateCustomChallengePayload,
{ rejectValue: string }
>('challenges/createCustom', async (payload, { rejectWithValue }) => {
try {
return await createCustomChallenge(payload);
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
});
export const joinChallengeByCode = createAsyncThunk<
{ challenge: ChallengeDetail; progress: ChallengeProgress },
string,
{ rejectValue: string }
>('challenges/joinByCode', async (shareCode, { rejectWithValue }) => {
try {
const progress = await joinChallengeByCodeApi(shareCode);
const challenge = await getChallengeByShareCode(shareCode);
return { challenge: { ...challenge, progress }, progress };
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
});
const challengesSlice = createSlice({
name: 'challenges',
initialState,
reducers: {
resetJoinByCodeState: (state) => {
state.joinByCodeStatus = 'idle';
state.joinByCodeError = undefined;
},
},
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 incomingIds = new Set<string>();
action.payload.forEach((challenge) => {
incomingIds.add(challenge.id);
const source = challenge.source ?? ChallengeSource.SYSTEM;
const existing = state.entities[challenge.id];
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
});
Object.keys(state.entities).forEach((id) => {
if (!incomingIds.has(id) && !state.entities[id]?.isJoined) {
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.orderedIds = 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];
const source = detail.source ?? existing?.source ?? ChallengeSource.SYSTEM;
state.entities[detail.id] = { ...(existing ?? {}), ...detail, source };
})
.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;
}
});
builder
.addCase(createCustomChallengeThunk.pending, (state) => {
state.createStatus = 'loading';
state.createError = undefined;
})
.addCase(createCustomChallengeThunk.fulfilled, (state, action) => {
state.createStatus = 'succeeded';
state.createError = undefined;
const challenge = action.payload;
const existing = state.entities[challenge.id];
const source = ChallengeSource.CUSTOM;
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
state.orderedIds = [challenge.id, ...state.orderedIds.filter((id) => id !== challenge.id)];
})
.addCase(createCustomChallengeThunk.rejected, (state, action) => {
state.createStatus = 'failed';
state.createError = action.payload ?? toErrorMessage(action.error);
});
builder
.addCase(joinChallengeByCode.pending, (state) => {
state.joinByCodeStatus = 'loading';
state.joinByCodeError = undefined;
})
.addCase(joinChallengeByCode.fulfilled, (state, action) => {
state.joinByCodeStatus = 'succeeded';
state.joinByCodeError = undefined;
const { challenge, progress } = action.payload;
const existing = state.entities[challenge.id];
const source = challenge.source ?? existing?.source ?? ChallengeSource.SYSTEM;
const merged = { ...(existing ?? {}), ...challenge, progress, isJoined: true, source };
state.entities[challenge.id] = merged as ChallengeEntity;
state.orderedIds = [challenge.id, ...state.orderedIds.filter((id) => id !== challenge.id)];
})
.addCase(joinChallengeByCode.rejected, (state, action) => {
state.joinByCodeStatus = 'failed';
state.joinByCodeError = action.payload ?? toErrorMessage(action.error);
});
},
});
export const { resetJoinByCodeState } = challengesSlice.actions;
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
);
const selectChallengeOrder = createSelector(
[selectChallengesState],
(state) => state.orderedIds
);
export const selectChallengeList = createSelector(
[selectChallengeEntities, selectChallengeOrder],
(entities, orderedIds) => orderedIds.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
);
export const selectCustomChallengeList = createSelector(
[selectChallengeList],
(list) => list.filter((challenge) => challenge.source === ChallengeSource.CUSTOM)
);
export const selectOfficialChallengeList = createSelector(
[selectChallengeList],
(list) => list.filter((challenge) => challenge.source !== ChallengeSource.CUSTOM)
);
const formatNumberWithSeparator = (value: number): string =>
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const formatMonthDay = (input: string | number | 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;
};
const deriveStatus = (challenge: ChallengeEntity): ChallengeStatus => {
if (challenge.status) return challenge.status;
if (challenge.challengeState === ChallengeState.ARCHIVED) {
return 'expired';
}
const now = dayjs();
const start = challenge.startAt ? dayjs(challenge.startAt) : null;
const end = challenge.endAt ? dayjs(challenge.endAt) : null;
if (start?.isValid() && start.isAfter(now)) {
return 'upcoming';
}
if (end?.isValid() && end.isBefore(now)) {
return 'expired';
}
return 'ongoing';
};
const FALLBACK_CHALLENGE_IMAGE =
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1000&q=80';
export type ChallengeCardViewModel = {
id: string;
title: string;
image: string;
dateRange: string;
participantsLabel: string;
status: ChallengeStatus;
isJoined: boolean;
endAt?: string | number;
periodLabel?: string;
durationLabel: string;
requirementLabel: string;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
progress?: ChallengeProgress;
avatars: string[];
source?: ChallengeSource;
shareCode?: string | null;
challengeState?: ChallengeState;
progressUnit?: string;
targetValue?: number;
isCreator?: boolean;
};
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
challenges.map<ChallengeCardViewModel>((challenge) => {
const participants =
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
return {
id: challenge.id,
title: challenge.title,
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
dateRange: buildDateRangeLabel(challenge),
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
status: deriveStatus(challenge),
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: [],
source: challenge.source,
shareCode: challenge.shareCode ?? null,
challengeState: challenge.challengeState,
progressUnit: challenge.progressUnit,
targetValue: challenge.targetValue,
isCreator: challenge.isCreator,
};
})
);
export const selectCustomChallengeCards = createSelector(
[selectCustomChallengeList],
(challenges) =>
challenges.map<ChallengeCardViewModel>((challenge) => {
const participants =
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
return {
id: challenge.id,
title: challenge.title,
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
dateRange: buildDateRangeLabel(challenge),
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
status: deriveStatus(challenge),
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: [],
source: challenge.source ?? ChallengeSource.CUSTOM,
shareCode: challenge.shareCode ?? null,
challengeState: challenge.challengeState,
progressUnit: challenge.progressUnit,
targetValue: challenge.targetValue,
isCreator: challenge.isCreator,
};
})
);
export const selectOfficialChallengeCards = createSelector(
[selectOfficialChallengeList],
(challenges) =>
challenges.map<ChallengeCardViewModel>((challenge) => {
const participants =
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
return {
id: challenge.id,
title: challenge.title,
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
dateRange: buildDateRangeLabel(challenge),
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
status: deriveStatus(challenge),
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: [],
source: challenge.source ?? ChallengeSource.SYSTEM,
shareCode: challenge.shareCode ?? null,
challengeState: challenge.challengeState,
progressUnit: challenge.progressUnit,
targetValue: challenge.targetValue,
isCreator: challenge.isCreator,
};
})
);
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]);
export const selectCreateChallengeStatus = createSelector(
[selectChallengesState],
(state) => state.createStatus
);
export const selectCreateChallengeError = createSelector(
[selectChallengesState],
(state) => state.createError
);
export const selectJoinByCodeStatus = createSelector(
[selectChallengesState],
(state) => state.joinByCodeStatus
);
export const selectJoinByCodeError = createSelector(
[selectChallengesState],
(state) => state.joinByCodeError
);