- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
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;
|
||
|
||
|