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