feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, updateCheckin } from '@/services/checkins';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, syncCheckin as syncCheckinApi, updateCheckin } from '@/services/checkins';
|
||||
import { createAction, createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export type CheckinExercise = {
|
||||
key: string;
|
||||
@@ -33,6 +33,7 @@ export type CheckinState = {
|
||||
byDate: Record<string, CheckinRecord>;
|
||||
currentDate: string | null;
|
||||
monthLoaded: Record<string, boolean>; // key: YYYY-MM, 标记该月数据是否已加载
|
||||
pendingSyncDates?: string[]; // 待同步的日期列表
|
||||
};
|
||||
|
||||
const initialState: CheckinState = {
|
||||
@@ -41,6 +42,33 @@ const initialState: CheckinState = {
|
||||
monthLoaded: {},
|
||||
};
|
||||
|
||||
function areItemsEqual(a: CheckinExercise[] | undefined, b: CheckinExercise[] | undefined): boolean {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const ai = a[i];
|
||||
const bi = b[i];
|
||||
if (!ai || !bi) return false;
|
||||
// 逐字段对比,避免 JSON 序列化引入顺序差异
|
||||
if (
|
||||
ai.key !== bi.key ||
|
||||
ai.name !== bi.name ||
|
||||
ai.category !== bi.category ||
|
||||
(ai.itemType ?? 'exercise') !== (bi.itemType ?? 'exercise') ||
|
||||
ai.sets !== bi.sets ||
|
||||
ai.reps !== bi.reps ||
|
||||
ai.durationSec !== bi.durationSec ||
|
||||
ai.restSec !== bi.restSec ||
|
||||
ai.note !== bi.note ||
|
||||
(!!ai.completed) !== (!!bi.completed)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureRecord(state: CheckinState, date: string): CheckinRecord {
|
||||
if (!state.byDate[date]) {
|
||||
state.byDate[date] = {
|
||||
@@ -52,6 +80,9 @@ function ensureRecord(state: CheckinState, date: string): CheckinRecord {
|
||||
return state.byDate[date];
|
||||
}
|
||||
|
||||
// 内部 action,用于标记需要同步的日期
|
||||
export const triggerAutoSync = createAction<{ date: string }>('checkin/triggerAutoSync');
|
||||
|
||||
const checkinSlice = createSlice({
|
||||
name: 'checkin',
|
||||
initialState,
|
||||
@@ -62,30 +93,55 @@ const checkinSlice = createSlice({
|
||||
},
|
||||
addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) {
|
||||
const rec = ensureRecord(state, action.payload.date);
|
||||
// 若同 key 已存在则覆盖参数(更接近用户“重新选择/编辑”的心智)
|
||||
// 若同 key 已存在则覆盖参数(更接近用户"重新选择/编辑"的心智)
|
||||
const idx = rec.items.findIndex((it) => it.key === action.payload.item.key);
|
||||
const normalized: CheckinExercise = { ...action.payload.item, completed: false };
|
||||
if (idx >= 0) rec.items[idx] = normalized; else rec.items.push(normalized);
|
||||
// 标记需要同步
|
||||
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||
state.pendingSyncDates.push(action.payload.date);
|
||||
}
|
||||
},
|
||||
removeExercise(state, action: PayloadAction<{ date: string; key: string }>) {
|
||||
const rec = ensureRecord(state, action.payload.date);
|
||||
rec.items = rec.items.filter((it) => it.key !== action.payload.key);
|
||||
// 标记需要同步
|
||||
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||
state.pendingSyncDates.push(action.payload.date);
|
||||
}
|
||||
},
|
||||
replaceExercises(state, action: PayloadAction<{ date: string; items: CheckinExercise[]; note?: string }>) {
|
||||
const rec = ensureRecord(state, action.payload.date);
|
||||
rec.items = (action.payload.items || []).map((it) => ({ ...it, completed: false }));
|
||||
if (typeof action.payload.note === 'string') rec.note = action.payload.note;
|
||||
// 标记需要同步
|
||||
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||
state.pendingSyncDates.push(action.payload.date);
|
||||
}
|
||||
},
|
||||
toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) {
|
||||
const rec = ensureRecord(state, action.payload.date);
|
||||
const idx = rec.items.findIndex((it) => it.key === action.payload.key);
|
||||
if (idx >= 0 && (rec.items[idx].itemType ?? 'exercise') === 'exercise') {
|
||||
rec.items[idx].completed = !rec.items[idx].completed;
|
||||
// 标记需要同步
|
||||
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||
state.pendingSyncDates.push(action.payload.date);
|
||||
}
|
||||
}
|
||||
},
|
||||
setNote(state, action: PayloadAction<{ date: string; note: string }>) {
|
||||
const rec = ensureRecord(state, action.payload.date);
|
||||
rec.note = action.payload.note;
|
||||
// 标记需要同步
|
||||
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||
state.pendingSyncDates.push(action.payload.date);
|
||||
}
|
||||
},
|
||||
resetDate(state, action: PayloadAction<string>) {
|
||||
delete state.byDate[action.payload];
|
||||
@@ -96,12 +152,27 @@ const checkinSlice = createSlice({
|
||||
.addCase(syncCheckin.fulfilled, (state, action) => {
|
||||
if (!action.payload) return;
|
||||
const { date, items, note, id } = action.payload;
|
||||
state.byDate[date] = {
|
||||
id: id || state.byDate[date]?.id || `rec_${date}`,
|
||||
date,
|
||||
items: items || [],
|
||||
note,
|
||||
};
|
||||
const prev = state.byDate[date];
|
||||
const nextId = id || prev?.id || `rec_${date}`;
|
||||
const nextItems = items || [];
|
||||
const isSame = prev && prev.id === nextId && prev.note === note && areItemsEqual(prev.items, nextItems);
|
||||
if (isSame) return;
|
||||
state.byDate[date] = { id: nextId, date, items: nextItems, note };
|
||||
})
|
||||
.addCase(autoSyncCheckin.fulfilled, (state, action) => {
|
||||
if (!action.payload) return;
|
||||
const { date, items, note, id } = action.payload;
|
||||
const prev = state.byDate[date];
|
||||
if (prev) {
|
||||
// 更新 ID(如果服务器返回了新的 ID)
|
||||
if (id && id !== prev.id) {
|
||||
state.byDate[date] = { ...prev, id };
|
||||
}
|
||||
}
|
||||
// 清除同步标记
|
||||
if (state.pendingSyncDates) {
|
||||
state.pendingSyncDates = state.pendingSyncDates.filter(d => d !== date);
|
||||
}
|
||||
})
|
||||
.addCase(getDailyCheckins.fulfilled, (state, action) => {
|
||||
const date = action.payload.date as string | undefined;
|
||||
@@ -119,13 +190,18 @@ const checkinSlice = createSlice({
|
||||
}
|
||||
if (typeof rec.notes === 'string') note = rec.notes as string;
|
||||
}
|
||||
state.byDate[date] = {
|
||||
id: id || state.byDate[date]?.id || `rec_${date}`,
|
||||
date,
|
||||
items: mergedItems,
|
||||
note,
|
||||
raw: list,
|
||||
};
|
||||
const prev = state.byDate[date];
|
||||
const nextId = id || prev?.id || `rec_${date}`;
|
||||
const shouldUseRaw = (mergedItems?.length ?? 0) === 0;
|
||||
const isSameMain = prev && prev.id === nextId && prev.note === note && areItemsEqual(prev.items, mergedItems);
|
||||
if (isSameMain) {
|
||||
// 若本地已有 items,则无需更新 raw 以免触发无效渲染
|
||||
if (!shouldUseRaw) return;
|
||||
// 若仅用于展示原始记录(本地 items 为空),允许更新 raw
|
||||
state.byDate[date] = { ...prev, raw: list } as CheckinRecord;
|
||||
return;
|
||||
}
|
||||
state.byDate[date] = { id: nextId, date, items: mergedItems, note, raw: shouldUseRaw ? list : prev?.raw };
|
||||
})
|
||||
.addCase(loadMonthCheckins.fulfilled, (state, action) => {
|
||||
const monthKey = action.payload.monthKey;
|
||||
@@ -168,6 +244,38 @@ export const syncCheckin = createAsyncThunk('checkin/sync', async (record: { dat
|
||||
return { id: newId, date: record.date, items: record.items, note: record.note };
|
||||
});
|
||||
|
||||
// 自动同步:当数据发生变动时自动调用
|
||||
export const autoSyncCheckin = createAsyncThunk(
|
||||
'checkin/autoSync',
|
||||
async (payload: { date: string }, { getState, dispatch }) => {
|
||||
const state = getState() as any;
|
||||
const record = state?.checkin?.byDate?.[payload.date];
|
||||
if (!record) return null;
|
||||
|
||||
// 只有当有实际数据时才同步
|
||||
if (!record.items || record.items.length === 0) return null;
|
||||
|
||||
try {
|
||||
const result = await syncCheckinApi({
|
||||
date: payload.date,
|
||||
items: record.items,
|
||||
note: record.note,
|
||||
id: record.id,
|
||||
});
|
||||
return {
|
||||
date: payload.date,
|
||||
items: record.items,
|
||||
note: record.note,
|
||||
id: result.id || record.id
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('自动同步失败:', error);
|
||||
// 不抛出错误,避免影响用户操作
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取当天打卡列表(用于进入页面时拉取最新云端数据)
|
||||
export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => {
|
||||
const dateParam = date ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import challengeReducer from './challengeSlice';
|
||||
import checkinReducer from './checkinSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import trainingPlanReducer from './trainingPlanSlice';
|
||||
import userReducer from './userSlice';
|
||||
|
||||
// 创建监听器中间件来处理自动同步
|
||||
const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
// 监听所有数据变动的 actions,触发自动同步
|
||||
const syncActions = [addExercise, removeExercise, replaceExercises, toggleExerciseCompleted, setNote];
|
||||
syncActions.forEach(action => {
|
||||
listenerMiddleware.startListening({
|
||||
actionCreator: action,
|
||||
effect: async (action, listenerApi) => {
|
||||
const state = listenerApi.getState() as any;
|
||||
const date = action.payload?.date;
|
||||
|
||||
if (!date) return;
|
||||
|
||||
// 延迟一下,避免在同一事件循环中重复触发
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 检查是否还有待同步的日期
|
||||
const currentState = listenerApi.getState() as any;
|
||||
const pendingSyncDates = currentState?.checkin?.pendingSyncDates || [];
|
||||
|
||||
if (pendingSyncDates.includes(date)) {
|
||||
listenerApi.dispatch(autoSyncCheckin({ date }));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
@@ -11,7 +39,8 @@ export const store = configureStore({
|
||||
checkin: checkinReducer,
|
||||
trainingPlan: trainingPlanReducer,
|
||||
},
|
||||
// React Native 环境默认即可
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreateTrainingPlanDto, trainingPlanApi } from '@/services/trainingPlanApi';
|
||||
import { CreateTrainingPlanDto, trainingPlanApi, TrainingPlanSummary } from '@/services/trainingPlanApi';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
@@ -29,6 +29,7 @@ export type TrainingPlan = {
|
||||
export type TrainingPlanState = {
|
||||
plans: TrainingPlan[];
|
||||
currentId?: string | null;
|
||||
editingId?: string | null;
|
||||
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -50,6 +51,7 @@ function nextMondayISO(): string {
|
||||
const initialState: TrainingPlanState = {
|
||||
plans: [],
|
||||
currentId: null,
|
||||
editingId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
draft: {
|
||||
@@ -72,17 +74,7 @@ export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, {
|
||||
// 尝试从服务器获取数据
|
||||
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
|
||||
console.log('response', response);
|
||||
const plans: TrainingPlan[] = response.list.map(summary => ({
|
||||
id: summary.id,
|
||||
createdAt: summary.createdAt,
|
||||
startDate: summary.startDate,
|
||||
goal: summary.goal as PlanGoal,
|
||||
mode: 'daysOfWeek', // 默认值,需要从详情获取
|
||||
daysOfWeek: [],
|
||||
sessionsPerWeek: 3,
|
||||
preferredTimeOfDay: '',
|
||||
name: '',
|
||||
}));
|
||||
const plans: TrainingPlanSummary[] = response.list;
|
||||
|
||||
// 读取最后一次使用的 currentId(从本地存储)
|
||||
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
||||
@@ -170,6 +162,89 @@ export const saveDraftAsPlan = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载某个计划用于编辑:会写入 draft 与 editingId
|
||||
*/
|
||||
export const loadPlanForEdit = createAsyncThunk(
|
||||
'trainingPlan/loadPlanForEdit',
|
||||
async (id: string, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const detail = await trainingPlanApi.detail(id);
|
||||
const draft = {
|
||||
startDate: detail.startDate,
|
||||
name: detail.name,
|
||||
mode: detail.mode,
|
||||
daysOfWeek: detail.daysOfWeek,
|
||||
sessionsPerWeek: detail.sessionsPerWeek,
|
||||
goal: detail.goal as PlanGoal,
|
||||
startWeightKg: detail.startWeightKg ?? undefined,
|
||||
preferredTimeOfDay: detail.preferredTimeOfDay,
|
||||
} as TrainingPlanState['draft'];
|
||||
return { id, draft } as { id: string; draft: TrainingPlanState['draft'] };
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || '加载计划详情失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 使用当前 draft 更新 editingId 对应的计划
|
||||
*/
|
||||
export const updatePlanFromDraft = createAsyncThunk(
|
||||
'trainingPlan/updatePlanFromDraft',
|
||||
async (_: void, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const s = (getState() as any).trainingPlan as TrainingPlanState;
|
||||
if (!s.editingId) throw new Error('无有效编辑对象');
|
||||
const draft = s.draft;
|
||||
const dto: CreateTrainingPlanDto = {
|
||||
startDate: draft.startDate,
|
||||
name: draft.name,
|
||||
mode: draft.mode,
|
||||
daysOfWeek: draft.daysOfWeek,
|
||||
sessionsPerWeek: draft.sessionsPerWeek,
|
||||
goal: draft.goal,
|
||||
startWeightKg: draft.startWeightKg,
|
||||
preferredTimeOfDay: draft.preferredTimeOfDay,
|
||||
};
|
||||
const resp = await trainingPlanApi.update(s.editingId, dto);
|
||||
const updated: TrainingPlan = {
|
||||
id: resp.id,
|
||||
createdAt: resp.createdAt,
|
||||
startDate: resp.startDate,
|
||||
mode: resp.mode,
|
||||
daysOfWeek: resp.daysOfWeek,
|
||||
sessionsPerWeek: resp.sessionsPerWeek,
|
||||
goal: resp.goal as PlanGoal,
|
||||
startWeightKg: resp.startWeightKg ?? undefined,
|
||||
preferredTimeOfDay: resp.preferredTimeOfDay,
|
||||
name: resp.name,
|
||||
};
|
||||
// 更新本地 plans
|
||||
const idx = (s.plans || []).findIndex(p => p.id === updated.id);
|
||||
const nextPlans = [...(s.plans || [])];
|
||||
if (idx >= 0) nextPlans[idx] = updated; else nextPlans.push(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
||||
return { plans: nextPlans } as { plans: TrainingPlan[] };
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || '更新训练计划失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 激活计划
|
||||
export const activatePlan = createAsyncThunk(
|
||||
'trainingPlan/activatePlan',
|
||||
async (planId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await trainingPlanApi.activate(planId);
|
||||
return { id: planId } as { id: string };
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || '激活训练计划失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** 删除计划 */
|
||||
export const deletePlan = createAsyncThunk(
|
||||
'trainingPlan/deletePlan',
|
||||
@@ -244,6 +319,9 @@ const trainingPlanSlice = createSlice({
|
||||
clearError(state) {
|
||||
state.error = null;
|
||||
},
|
||||
setEditingId(state, action: PayloadAction<string | null>) {
|
||||
state.editingId = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
@@ -281,6 +359,48 @@ const trainingPlanSlice = createSlice({
|
||||
state.loading = false;
|
||||
state.error = action.payload as string || '创建训练计划失败';
|
||||
})
|
||||
// loadPlanForEdit
|
||||
.addCase(loadPlanForEdit.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loadPlanForEdit.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.editingId = action.payload.id;
|
||||
state.draft = action.payload.draft;
|
||||
})
|
||||
.addCase(loadPlanForEdit.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string || '加载计划详情失败';
|
||||
})
|
||||
// updatePlanFromDraft
|
||||
.addCase(updatePlanFromDraft.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updatePlanFromDraft.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.plans = action.payload.plans;
|
||||
})
|
||||
.addCase(updatePlanFromDraft.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string || '更新训练计划失败';
|
||||
})
|
||||
// activatePlan
|
||||
.addCase(activatePlan.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(activatePlan.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.currentId = action.payload.id;
|
||||
// 保存到本地存储
|
||||
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload.id);
|
||||
})
|
||||
.addCase(activatePlan.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string || '激活训练计划失败';
|
||||
})
|
||||
// deletePlan
|
||||
.addCase(deletePlan.pending, (state) => {
|
||||
state.loading = true;
|
||||
@@ -311,6 +431,7 @@ export const {
|
||||
setStartDateNextMonday,
|
||||
resetDraft,
|
||||
clearError,
|
||||
setEditingId,
|
||||
} = trainingPlanSlice.actions;
|
||||
|
||||
export default trainingPlanSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user