feat: 更新应用版本和主题设置

- 将应用版本更新至 1.0.3,修改相关配置文件
- 强制全局使用浅色主题,确保一致的用户体验
- 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划
- 优化打卡功能,支持自动同步打卡记录至服务器
- 更新样式以适应新功能的展示和交互
This commit is contained in:
2025-08-14 22:23:45 +08:00
parent 56d4c7fd7f
commit 807e185761
21 changed files with 677 additions and 141 deletions

View File

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

View File

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

View File

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