- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, syncCheckin as syncCheckinApi, updateCheckin } from '@/services/checkins';
|
||
import { createAction, createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||
|
||
export type CheckinExercise = {
|
||
key: string;
|
||
name: string;
|
||
category: string;
|
||
/**
|
||
* itemType
|
||
* - exercise: 正常训练动作(默认)
|
||
* - rest: 组间/动作间休息(仅展示,不可勾选完成)
|
||
* - note: 备注/口令提示(仅展示)
|
||
*/
|
||
itemType?: 'exercise' | 'rest' | 'note';
|
||
sets: number; // 组数(rest/note 可为 0)
|
||
reps?: number; // 每组重复(计次型)
|
||
durationSec?: number; // 每组时长(计时型)
|
||
restSec?: number; // 休息时长(当 itemType=rest 时使用)
|
||
note?: string; // 备注内容(当 itemType=note 时使用)
|
||
completed?: boolean; // 是否已完成该动作
|
||
};
|
||
|
||
export type CheckinRecord = {
|
||
id: string;
|
||
date: string; // YYYY-MM-DD
|
||
items: CheckinExercise[];
|
||
note?: string;
|
||
// 保留后端原始返回,便于当 metrics.items 为空时做回退展示
|
||
raw?: any[];
|
||
};
|
||
|
||
export type CheckinState = {
|
||
byDate: Record<string, CheckinRecord>;
|
||
currentDate: string | null;
|
||
monthLoaded: Record<string, boolean>; // key: YYYY-MM, 标记该月数据是否已加载
|
||
pendingSyncDates?: string[]; // 待同步的日期列表
|
||
};
|
||
|
||
const initialState: CheckinState = {
|
||
byDate: {},
|
||
currentDate: null,
|
||
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] = {
|
||
id: `rec_${date}`,
|
||
date,
|
||
items: [],
|
||
};
|
||
}
|
||
return state.byDate[date];
|
||
}
|
||
|
||
// 内部 action,用于标记需要同步的日期
|
||
export const triggerAutoSync = createAction<{ date: string }>('checkin/triggerAutoSync');
|
||
|
||
const checkinSlice = createSlice({
|
||
name: 'checkin',
|
||
initialState,
|
||
reducers: {
|
||
setCurrentDate(state, action: PayloadAction<string>) {
|
||
state.currentDate = action.payload; // 期望格式 YYYY-MM-DD
|
||
ensureRecord(state, action.payload);
|
||
},
|
||
addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) {
|
||
const rec = ensureRecord(state, action.payload.date);
|
||
// 若同 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];
|
||
},
|
||
},
|
||
extraReducers: (builder) => {
|
||
builder
|
||
.addCase(syncCheckin.fulfilled, (state, action) => {
|
||
if (!action.payload) return;
|
||
const { date, items, note, id } = action.payload;
|
||
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;
|
||
const list = action.payload.list || [];
|
||
if (!date) return;
|
||
let mergedItems: CheckinExercise[] = [];
|
||
let note: string | undefined = undefined;
|
||
let id: string | undefined = state.byDate[date]?.id;
|
||
for (const rec of list) {
|
||
if (!rec) continue;
|
||
if (rec.id && !id) id = String(rec.id);
|
||
const itemsFromMetrics = rec?.metrics?.items ?? rec?.items;
|
||
if (Array.isArray(itemsFromMetrics)) {
|
||
mergedItems = itemsFromMetrics as CheckinExercise[];
|
||
}
|
||
if (typeof rec.notes === 'string') note = rec.notes as string;
|
||
}
|
||
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;
|
||
const merged = action.payload.byDate;
|
||
for (const d of Object.keys(merged)) {
|
||
const prev = state.byDate[d];
|
||
const next = merged[d];
|
||
const items = (next.items && next.items.length > 0) ? next.items : (prev?.items ?? []);
|
||
const note = (typeof next.note === 'string') ? next.note : prev?.note;
|
||
const id = next.id || prev?.id || `rec_${d}`;
|
||
const raw = prev?.raw ?? (next as any)?.raw;
|
||
state.byDate[d] = { id, date: d, items, note, raw } as CheckinRecord;
|
||
}
|
||
state.monthLoaded[monthKey] = true;
|
||
});
|
||
},
|
||
});
|
||
|
||
export const { setCurrentDate, addExercise, removeExercise, replaceExercises, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions;
|
||
export default checkinSlice.reducer;
|
||
|
||
// Thunks
|
||
export const syncCheckin = createAsyncThunk('checkin/sync', async (record: { date: string; items: CheckinExercise[]; note?: string; id?: string }, { getState }) => {
|
||
const state = getState() as any;
|
||
const existingId: string | undefined = record.id || state?.checkin?.byDate?.[record.date]?.id;
|
||
const metrics = { items: record.items } as any;
|
||
if (!existingId || existingId.startsWith('rec_')) {
|
||
const created = await createCheckin({
|
||
title: '每日训练打卡',
|
||
checkinDate: record.date,
|
||
notes: record.note,
|
||
metrics,
|
||
startedAt: new Date().toISOString(),
|
||
});
|
||
const newId = (created as any)?.id;
|
||
return { id: newId, date: record.date, items: record.items, note: record.note };
|
||
}
|
||
const updated = await updateCheckin({ id: existingId, notes: record.note, metrics });
|
||
const newId = (updated as any)?.id ?? existingId;
|
||
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);
|
||
const list = await fetchDailyCheckins(dateParam);
|
||
try { console.log('getDailyCheckins', { date: dateParam, count: Array.isArray(list) ? list.length : -1 }); } catch { }
|
||
return { date: dateParam, list } as { date?: string; list: any[] };
|
||
});
|
||
|
||
// 按月加载:优先使用区间接口,失败则逐日回退
|
||
export const loadMonthCheckins = createAsyncThunk(
|
||
'checkin/loadMonth',
|
||
async (payload: { year: number; month1Based: number }, { getState }) => {
|
||
const { year, month1Based } = payload;
|
||
const pad = (n: number) => `${n}`.padStart(2, '0');
|
||
const monthKey = `${year}-${pad(month1Based)}`;
|
||
|
||
const start = `${year}-${pad(month1Based)}-01`;
|
||
const endDate = new Date(year, month1Based, 0).getDate();
|
||
const end = `${year}-${pad(month1Based)}-${pad(endDate)}`;
|
||
|
||
try {
|
||
const list = await fetchCheckinsInRange(start, end);
|
||
const byDate: Record<string, CheckinRecord> = {};
|
||
for (const rec of list) {
|
||
const date = rec?.checkinDate || rec?.date;
|
||
if (!date) continue;
|
||
const id = rec?.id ? String(rec.id) : `rec_${date}`;
|
||
const items = (rec?.metrics?.items ?? rec?.items) ?? [];
|
||
const note = typeof rec?.notes === 'string' ? rec.notes : undefined;
|
||
byDate[date] = { id, date, items, note };
|
||
}
|
||
return { monthKey, byDate } as { monthKey: string; byDate: Record<string, CheckinRecord> };
|
||
} catch {
|
||
// 回退逐日请求(并行)
|
||
const endNum = new Date(year, month1Based, 0).getDate();
|
||
const dates = Array.from({ length: endNum }, (_, i) => `${year}-${pad(month1Based)}-${pad(i + 1)}`);
|
||
const results = await Promise.all(
|
||
dates.map(async (d) => ({ d, list: await fetchDailyCheckins(d) }))
|
||
);
|
||
const byDate: Record<string, CheckinRecord> = {};
|
||
for (const { d, list } of results) {
|
||
let items: CheckinExercise[] = [];
|
||
let note: string | undefined;
|
||
let id: string | undefined;
|
||
for (const rec of list) {
|
||
if (rec?.id && !id) id = String(rec.id);
|
||
const metricsItems = rec?.metrics?.items ?? rec?.items;
|
||
if (Array.isArray(metricsItems)) items = metricsItems as CheckinExercise[];
|
||
if (typeof rec?.notes === 'string') note = rec.notes as string;
|
||
}
|
||
byDate[d] = { id: id || `rec_${d}`, date: d, items, note };
|
||
}
|
||
return { monthKey, byDate } as { monthKey: string; byDate: Record<string, CheckinRecord> };
|
||
}
|
||
},
|
||
{
|
||
condition: (payload, { getState }) => {
|
||
const state = getState() as any;
|
||
const pad = (n: number) => `${n}`.padStart(2, '0');
|
||
const monthKey = `${payload.year}-${pad(payload.month1Based)}`;
|
||
return !state?.checkin?.monthLoaded?.[monthKey];
|
||
},
|
||
}
|
||
);
|
||
|
||
|