Files
digital-pilates/store/trainingPlanSlice.ts
richarjiang 807e185761 feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件
- 强制全局使用浅色主题,确保一致的用户体验
- 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划
- 优化打卡功能,支持自动同步打卡记录至服务器
- 更新样式以适应新功能的展示和交互
2025-08-14 22:23:45 +08:00

440 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CreateTrainingPlanDto, trainingPlanApi, TrainingPlanSummary } from '@/services/trainingPlanApi';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
export type PlanGoal =
| 'postpartum_recovery' // 产后恢复
| 'fat_loss' // 减脂塑形
| 'posture_correction' // 体态矫正
| 'core_strength' // 核心力量
| 'flexibility' // 柔韧灵活
| 'rehab' // 康复保健
| 'stress_relief'; // 释压放松
export type TrainingPlan = {
id: string;
createdAt: string; // ISO
startDate: string; // ISO (当天或下周一)
mode: PlanMode;
daysOfWeek: number[]; // 0(日) - 6(六)
sessionsPerWeek: number; // 1..7
goal: PlanGoal | '';
startWeightKg?: number;
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
name?: string;
};
export type TrainingPlanState = {
plans: TrainingPlan[];
currentId?: string | null;
editingId?: string | null;
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
loading: boolean;
error: string | null;
};
const STORAGE_KEY_LEGACY_SINGLE = '@training_plan';
const STORAGE_KEY_LIST = '@training_plans';
function nextMondayISO(): string {
const now = new Date();
const day = now.getDay();
const diff = (8 - day) % 7 || 7; // 距下周一的天数
const next = new Date(now);
next.setDate(now.getDate() + diff);
next.setHours(0, 0, 0, 0);
return next.toISOString();
}
const initialState: TrainingPlanState = {
plans: [],
currentId: null,
editingId: null,
loading: false,
error: null,
draft: {
startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
mode: 'daysOfWeek',
daysOfWeek: [1, 3, 5],
sessionsPerWeek: 3,
goal: '',
startWeightKg: undefined,
preferredTimeOfDay: '',
name: '',
},
};
/**
* 从服务器加载训练计划列表,同时支持本地缓存迁移
*/
export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, { rejectWithValue }) => {
try {
// 尝试从服务器获取数据
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
console.log('response', response);
const plans: TrainingPlanSummary[] = response.list;
// 读取最后一次使用的 currentId从本地存储
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch (error: any) {
// 如果API调用失败回退到本地存储
console.warn('API调用失败使用本地存储:', error.message);
// 新版:列表
const listStr = await AsyncStorage.getItem(STORAGE_KEY_LIST);
if (listStr) {
try {
const plans = JSON.parse(listStr) as TrainingPlan[];
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch {
// 解析失败则视为无数据
}
}
// 旧版:单计划
const legacyStr = await AsyncStorage.getItem(STORAGE_KEY_LEGACY_SINGLE);
if (legacyStr) {
try {
const legacy = JSON.parse(legacyStr) as TrainingPlan;
const plans = [legacy];
const currentId = legacy.id;
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch {
// ignore
}
}
return { plans: [], currentId: null } as { plans: TrainingPlan[]; currentId: string | null };
}
});
/**
* 将当前 draft 保存为新计划并设为当前计划。
*/
export const saveDraftAsPlan = createAsyncThunk(
'trainingPlan/saveDraftAsPlan',
async (_: void, { getState, rejectWithValue }) => {
try {
const s = (getState() as any).trainingPlan as TrainingPlanState;
const draft = s.draft;
const createDto: 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 response = await trainingPlanApi.create(createDto);
const plan: TrainingPlan = {
id: response.id,
createdAt: response.createdAt,
startDate: response.startDate,
mode: response.mode,
daysOfWeek: response.daysOfWeek,
sessionsPerWeek: response.sessionsPerWeek,
goal: response.goal as PlanGoal,
startWeightKg: response.startWeightKg || undefined,
preferredTimeOfDay: response.preferredTimeOfDay,
name: response.name,
};
const nextPlans = [...(s.plans || []), plan];
// 同时保存到本地存储作为缓存
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, plan.id);
return { plans: nextPlans, currentId: plan.id } as { plans: TrainingPlan[]; currentId: string };
} catch (error: any) {
return rejectWithValue(error.message || '创建训练计划失败');
}
}
);
/**
* 加载某个计划用于编辑:会写入 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',
async (planId: string, { getState, rejectWithValue }) => {
try {
const s = (getState() as any).trainingPlan as TrainingPlanState;
// 调用API删除
await trainingPlanApi.delete(planId);
// 更新本地状态
const nextPlans = (s.plans || []).filter((p) => p.id !== planId);
let nextCurrentId = s.currentId || null;
if (nextCurrentId === planId) {
nextCurrentId = nextPlans.length > 0 ? nextPlans[nextPlans.length - 1].id : null;
}
// 同时更新本地存储
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, nextCurrentId ?? '');
return { plans: nextPlans, currentId: nextCurrentId } as { plans: TrainingPlan[]; currentId: string | null };
} catch (error: any) {
return rejectWithValue(error.message || '删除训练计划失败');
}
}
);
const trainingPlanSlice = createSlice({
name: 'trainingPlan',
initialState,
reducers: {
setCurrentPlan(state, action: PayloadAction<string | null | undefined>) {
state.currentId = action.payload ?? null;
// 保存到本地存储
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload ?? '');
},
setMode(state, action: PayloadAction<PlanMode>) {
state.draft.mode = action.payload;
},
toggleDayOfWeek(state, action: PayloadAction<number>) {
const d = action.payload;
const set = new Set(state.draft.daysOfWeek);
if (set.has(d)) set.delete(d); else set.add(d);
state.draft.daysOfWeek = Array.from(set).sort();
},
setSessionsPerWeek(state, action: PayloadAction<number>) {
const n = Math.min(7, Math.max(1, action.payload));
state.draft.sessionsPerWeek = n;
},
setGoal(state, action: PayloadAction<TrainingPlan['goal']>) {
state.draft.goal = action.payload;
},
setStartWeight(state, action: PayloadAction<number | undefined>) {
state.draft.startWeightKg = action.payload;
},
setStartDate(state, action: PayloadAction<string>) {
state.draft.startDate = action.payload;
},
setPreferredTime(state, action: PayloadAction<TrainingPlan['preferredTimeOfDay']>) {
state.draft.preferredTimeOfDay = action.payload;
},
setName(state, action: PayloadAction<string>) {
state.draft.name = action.payload;
},
setStartDateNextMonday(state) {
state.draft.startDate = nextMondayISO();
},
resetDraft(state) {
state.draft = initialState.draft;
},
clearError(state) {
state.error = null;
},
setEditingId(state, action: PayloadAction<string | null>) {
state.editingId = action.payload;
},
},
extraReducers: (builder) => {
builder
// loadPlans
.addCase(loadPlans.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(loadPlans.fulfilled, (state, action) => {
state.loading = false;
state.plans = action.payload.plans;
state.currentId = action.payload.currentId;
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
const current = state.plans.find((p) => p.id === state.currentId) || state.plans[state.plans.length - 1];
if (current) {
const { id, createdAt, ...rest } = current;
state.draft = { ...rest };
}
})
.addCase(loadPlans.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string || '加载训练计划失败';
})
// saveDraftAsPlan
.addCase(saveDraftAsPlan.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(saveDraftAsPlan.fulfilled, (state, action) => {
state.loading = false;
state.plans = action.payload.plans;
state.currentId = action.payload.currentId;
})
.addCase(saveDraftAsPlan.rejected, (state, action) => {
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;
state.error = null;
})
.addCase(deletePlan.fulfilled, (state, action) => {
state.loading = false;
state.plans = action.payload.plans;
state.currentId = action.payload.currentId;
})
.addCase(deletePlan.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string || '删除训练计划失败';
});
},
});
export const {
setCurrentPlan,
setMode,
toggleDayOfWeek,
setSessionsPerWeek,
setGoal,
setStartWeight,
setStartDate,
setPreferredTime,
setName,
setStartDateNextMonday,
resetDraft,
clearError,
setEditingId,
} = trainingPlanSlice.actions;
export default trainingPlanSlice.reducer;