feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。 - 新增勋章列表页面,支持已获得和待解锁勋章的分类展示 - 在个人中心添加勋章预览模块,显示前3个勋章和总数统计 - 实现勋章展示弹窗,支持动画效果和玻璃态UI - 添加勋章分享功能,可生成分享卡片 - 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑 - 添加勋章服务 API 封装,支持获取勋章列表和标记已展示 - 完善中英文国际化文案
This commit is contained in:
120
store/badgesSlice.ts
Normal file
120
store/badgesSlice.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { BadgeDto, BadgeRarity } from '@/services/badges';
|
||||
import { getAvailableBadges } from '@/services/badges';
|
||||
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { RootState } from './index';
|
||||
|
||||
export interface BadgesState {
|
||||
items: BadgeDto[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastFetched: string | null;
|
||||
}
|
||||
|
||||
const initialState: BadgesState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
};
|
||||
|
||||
export const fetchAvailableBadges = createAsyncThunk<BadgeDto[], void, { rejectValue: string }>(
|
||||
'badges/fetchAvailable',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getAvailableBadges();
|
||||
} catch (error: any) {
|
||||
const message = error?.message ?? '获取勋章列表失败';
|
||||
return rejectWithValue(message);
|
||||
}
|
||||
},
|
||||
{
|
||||
condition: (_, { getState }) => {
|
||||
const state = getState() as RootState;
|
||||
const { loading } = state.badges ?? {};
|
||||
return !loading;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const badgesSlice = createSlice({
|
||||
name: 'badges',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchAvailableBadges.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchAvailableBadges.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.items = action.payload;
|
||||
|
||||
state.lastFetched = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchAvailableBadges.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = typeof action.payload === 'string' ? action.payload : action.error.message ?? '加载勋章失败';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default badgesSlice.reducer;
|
||||
|
||||
const selectBadgesSlice = (state: RootState) => state.badges;
|
||||
export const selectBadgesState = selectBadgesSlice;
|
||||
export const selectBadgesLoading = (state: RootState) => state.badges.loading;
|
||||
export const selectBadgesError = (state: RootState) => state.badges.error;
|
||||
export const selectBadges = (state: RootState) => state.badges.items;
|
||||
|
||||
const rarityPriority: Record<BadgeRarity, number> = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
rare: 2,
|
||||
epic: 3,
|
||||
legendary: 4,
|
||||
};
|
||||
|
||||
const compareBadges = (a: BadgeDto, b: BadgeDto) => {
|
||||
if (a.isAwarded !== b.isAwarded) {
|
||||
return a.isAwarded ? -1 : 1;
|
||||
}
|
||||
|
||||
const sortOrderA = a.sortOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const sortOrderB = b.sortOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
if (sortOrderA !== sortOrderB) {
|
||||
return sortOrderA - sortOrderB;
|
||||
}
|
||||
|
||||
if (a.isAwarded && b.isAwarded) {
|
||||
const earnedDiff = (b.earnedAt ? dayjs(b.earnedAt).valueOf() : 0) - (a.earnedAt ? dayjs(a.earnedAt).valueOf() : 0);
|
||||
if (earnedDiff !== 0) {
|
||||
return earnedDiff;
|
||||
}
|
||||
}
|
||||
|
||||
const rarityKeyA = a.rarity ?? 'common';
|
||||
const rarityKeyB = b.rarity ?? 'common';
|
||||
const rarityDiff = rarityPriority[rarityKeyB] - rarityPriority[rarityKeyA];
|
||||
if (rarityDiff !== 0) {
|
||||
return rarityDiff;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
};
|
||||
|
||||
export const selectSortedBadges = createSelector([selectBadges], (items) => {
|
||||
return [...items].sort(compareBadges);
|
||||
});
|
||||
|
||||
export const selectBadgeCounts = createSelector([selectBadges], (items) => {
|
||||
const earned = items.filter((badge) => badge.isAwarded).length;
|
||||
return {
|
||||
earned,
|
||||
total: items.length,
|
||||
};
|
||||
});
|
||||
|
||||
export const selectBadgePreview = createSelector([selectSortedBadges], (items) => items.slice(0, 3));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { persistActiveFastingSchedule } from '@/utils/fasting';
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import badgesReducer from './badgesSlice';
|
||||
import challengesReducer from './challengesSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import circumferenceReducer from './circumferenceSlice';
|
||||
@@ -111,6 +112,7 @@ export const store = configureStore({
|
||||
water: waterReducer,
|
||||
fasting: fastingReducer,
|
||||
medications: medicationsReducer,
|
||||
badges: badgesReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||
|
||||
Reference in New Issue
Block a user