feat(challenges): 添加自定义挑战功能和多语言支持

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

View File

@@ -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
);