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

345 lines
14 KiB
TypeScript
Raw Permalink 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 { 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];
},
}
);