feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等 - 实现挑战邀请码系统,支持通过邀请码加入自定义挑战 - 完善挑战详情页面的多语言翻译支持 - 优化用户认证状态检查逻辑,使用token作为主要判断依据 - 添加阿里字体文件支持,提升UI显示效果 - 改进确认弹窗组件,支持Liquid Glass效果和自定义内容 - 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
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,
|
||||
@@ -21,9 +29,9 @@ export type ChallengeProgress = ChallengeProgressDto;
|
||||
export type RankingItem = RankingItemDto;
|
||||
export type ChallengeSummary = ChallengeListItemDto;
|
||||
export type ChallengeDetail = ChallengeDetailDto;
|
||||
export type { ChallengeStatus };
|
||||
export type { ChallengeSource, ChallengeState, ChallengeStatus };
|
||||
export type ChallengeEntity = ChallengeSummary & {
|
||||
summary?: string;
|
||||
summary?: string | null;
|
||||
rankings?: RankingItem[];
|
||||
userRank?: number;
|
||||
};
|
||||
@@ -38,7 +46,7 @@ type ChallengeRankingList = {
|
||||
|
||||
type ChallengesState = {
|
||||
entities: Record<string, ChallengeEntity>;
|
||||
order: string[];
|
||||
orderedIds: string[];
|
||||
listStatus: AsyncStatus;
|
||||
listError?: string;
|
||||
detailStatus: Record<string, AsyncStatus>;
|
||||
@@ -53,11 +61,15 @@ type ChallengesState = {
|
||||
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: {},
|
||||
order: [],
|
||||
orderedIds: [],
|
||||
listStatus: 'idle',
|
||||
listError: undefined,
|
||||
detailStatus: {},
|
||||
@@ -72,6 +84,10 @@ const initialState: ChallengesState = {
|
||||
rankingStatus: {},
|
||||
rankingLoadMoreStatus: {},
|
||||
rankingError: {},
|
||||
createStatus: 'idle',
|
||||
createError: undefined,
|
||||
joinByCodeStatus: 'idle',
|
||||
joinByCodeError: undefined,
|
||||
};
|
||||
|
||||
const toErrorMessage = (error: unknown): string => {
|
||||
@@ -168,10 +184,41 @@ export const fetchChallengeRankings = createAsyncThunk<
|
||||
}
|
||||
});
|
||||
|
||||
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: {},
|
||||
reducers: {
|
||||
resetJoinByCodeState: (state) => {
|
||||
state.joinByCodeStatus = 'idle';
|
||||
state.joinByCodeError = undefined;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchChallenges.pending, (state) => {
|
||||
@@ -181,18 +228,15 @@ const challengesSlice = createSlice({
|
||||
.addCase(fetchChallenges.fulfilled, (state, action) => {
|
||||
state.listStatus = 'succeeded';
|
||||
state.listError = undefined;
|
||||
const ids = new Set<string>();
|
||||
const incomingIds = new Set<string>();
|
||||
action.payload.forEach((challenge) => {
|
||||
ids.add(challenge.id);
|
||||
incomingIds.add(challenge.id);
|
||||
const source = challenge.source ?? ChallengeSource.SYSTEM;
|
||||
const existing = state.entities[challenge.id];
|
||||
if (existing) {
|
||||
Object.assign(existing, challenge);
|
||||
} else {
|
||||
state.entities[challenge.id] = { ...challenge };
|
||||
}
|
||||
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
|
||||
});
|
||||
Object.keys(state.entities).forEach((id) => {
|
||||
if (!ids.has(id)) {
|
||||
if (!incomingIds.has(id) && !state.entities[id]?.isJoined) {
|
||||
delete state.entities[id];
|
||||
delete state.detailStatus[id];
|
||||
delete state.detailError[id];
|
||||
@@ -204,7 +248,7 @@ const challengesSlice = createSlice({
|
||||
delete state.progressError[id];
|
||||
}
|
||||
});
|
||||
state.order = action.payload.map((item) => item.id);
|
||||
state.orderedIds = action.payload.map((item) => item.id);
|
||||
})
|
||||
.addCase(fetchChallenges.rejected, (state, action) => {
|
||||
state.listStatus = 'failed';
|
||||
@@ -220,11 +264,8 @@ const challengesSlice = createSlice({
|
||||
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 };
|
||||
}
|
||||
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;
|
||||
@@ -333,9 +374,50 @@ const challengesSlice = createSlice({
|
||||
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;
|
||||
@@ -355,20 +437,30 @@ export const selectChallengeEntities = createSelector(
|
||||
(state) => state.entities
|
||||
);
|
||||
|
||||
export const selectChallengeOrder = createSelector(
|
||||
const selectChallengeOrder = createSelector(
|
||||
[selectChallengesState],
|
||||
(state) => state.order
|
||||
(state) => state.orderedIds
|
||||
);
|
||||
|
||||
export const selectChallengeList = createSelector(
|
||||
[selectChallengeEntities, selectChallengeOrder],
|
||||
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
|
||||
(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 | undefined): string | undefined => {
|
||||
const formatMonthDay = (input: string | number | undefined): string | undefined => {
|
||||
if (!input) return undefined;
|
||||
const date = new Date(input);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
@@ -384,6 +476,26 @@ const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
||||
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;
|
||||
@@ -392,7 +504,7 @@ export type ChallengeCardViewModel = {
|
||||
participantsLabel: string;
|
||||
status: ChallengeStatus;
|
||||
isJoined: boolean;
|
||||
endAt?: string;
|
||||
endAt?: string | number;
|
||||
periodLabel?: string;
|
||||
durationLabel: string;
|
||||
requirementLabel: string;
|
||||
@@ -401,27 +513,109 @@ export type ChallengeCardViewModel = {
|
||||
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) => ({
|
||||
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: [],
|
||||
}))
|
||||
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) =>
|
||||
@@ -462,3 +656,23 @@ export const selectChallengeRankingLoadMoreStatus = (id: string) =>
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user