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

View File

@@ -5,65 +5,51 @@ import AsyncStorage from '@/utils/kvStore';
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
// 预加载的用户数据存储
let preloadedUserData: {
token: string | null;
profile: UserProfile;
privacyAgreed: boolean;
onboardingCompleted: boolean;
} | null = null;
// 预加载用户数据的函数
export async function preloadUserData() {
/**
* 同步加载用户数据(在 Redux store 初始化时立即执行)
* 使用 getItemSync 确保数据在 store 创建前就已加载
*/
function loadUserDataSync() {
try {
const [profileStr, privacyAgreedStr, token, onboardingCompletedStr] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
AsyncStorage.getItem(STORAGE_KEYS.authToken),
AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted),
]);
const profileStr = AsyncStorage.getItemSync(STORAGE_KEYS.userProfile);
const token = AsyncStorage.getItemSync(STORAGE_KEYS.authToken);
const onboardingCompletedStr = AsyncStorage.getItemSync(STORAGE_KEYS.onboardingCompleted);
let profile: UserProfile = {
memberNumber: 0
};
if (profileStr) {
try {
profile = JSON.parse(profileStr) as UserProfile;
} catch {
profile = {
memberNumber: 0
};
profile = { memberNumber: 0 };
}
}
const privacyAgreed = privacyAgreedStr === 'true';
const onboardingCompleted = onboardingCompletedStr === 'true';
// 如果有 token需要设置到 API 客户端
// 如果有 token需要异步设置到 API 客户端(但不阻塞初始化)
if (token) {
await setAuthToken(token);
setAuthToken(token).catch(err => {
console.error('设置 auth token 失败:', err);
});
}
preloadedUserData = { token, profile, privacyAgreed, onboardingCompleted };
return preloadedUserData;
return { token, profile, onboardingCompleted };
} catch (error) {
console.error('加载用户数据失败:', error);
preloadedUserData = {
console.error('同步加载用户数据失败:', error);
return {
token: null,
profile: {
memberNumber: 0
},
privacyAgreed: false,
profile: { memberNumber: 0 },
onboardingCompleted: false
};
return preloadedUserData;
}
}
// 获取预加载的用户数据
function getPreloadedUserData() {
return preloadedUserData || { token: null, profile: {}, privacyAgreed: false, onboardingCompleted: false };
}
// 在模块加载时立即同步加载用户数据
const preloadedUserData = loadUserDataSync();
export type Gender = 'male' | 'female' | '';
@@ -120,22 +106,23 @@ export type UserState = {
export const DEFAULT_MEMBER_NAME = '朋友';
const getInitialState = (): UserState => {
const preloaded = getPreloadedUserData();
// 使用模块加载时同步加载的数据
console.log('初始化 Redux state使用预加载数据:', preloadedUserData);
return {
token: preloaded.token,
token: preloadedUserData.token,
profile: {
name: DEFAULT_MEMBER_NAME,
isVip: false,
freeUsageCount: 3,
memberNumber: 0,
maxUsageCount: 5,
...preloaded.profile, // 合并预加载的用户资料
...preloadedUserData.profile, // 合并预加载的用户资料(包含 memberNumber
},
loading: false,
error: null,
weightHistory: [],
activityHistory: [],
onboardingCompleted: preloaded.onboardingCompleted, // 引导完成状态
onboardingCompleted: preloadedUserData.onboardingCompleted, // 引导完成状态
};
};
@@ -198,8 +185,11 @@ export const login = createAsyncThunk(
if (!token) throw new Error('登录响应缺少 token');
// 先持久化到本地存储
await AsyncStorage.setItem(STORAGE_KEYS.authToken, token);
await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {}));
// 再设置到 API 客户端(内部会同步更新 AsyncStorage
await setAuthToken(token);
return { token, profile } as { token: string; profile: UserProfile };
@@ -222,12 +212,15 @@ export const setOnboardingCompleted = createAsyncThunk('user/setOnboardingComple
});
export const logout = createAsyncThunk('user/logout', async () => {
// 先清除 API 客户端的 token内部会清除 AsyncStorage
await setAuthToken(null);
// 再清除其他本地存储数据
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed),
]);
await setAuthToken(null);
return true;
});