import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, updateCheckin } from '@/services/checkins'; import { 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; currentDate: string | null; monthLoaded: Record; // key: YYYY-MM, 标记该月数据是否已加载 }; const initialState: CheckinState = { byDate: {}, currentDate: null, monthLoaded: {}, }; function ensureRecord(state: CheckinState, date: string): CheckinRecord { if (!state.byDate[date]) { state.byDate[date] = { id: `rec_${date}`, date, items: [], }; } return state.byDate[date]; } const checkinSlice = createSlice({ name: 'checkin', initialState, reducers: { setCurrentDate(state, action: PayloadAction) { 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); }, 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); }, 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; }, 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; } }, setNote(state, action: PayloadAction<{ date: string; note: string }>) { const rec = ensureRecord(state, action.payload.date); rec.note = action.payload.note; }, resetDate(state, action: PayloadAction) { delete state.byDate[action.payload]; }, }, extraReducers: (builder) => { builder .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, }; }) .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; } state.byDate[date] = { id: id || state.byDate[date]?.id || `rec_${date}`, date, items: mergedItems, note, raw: list, }; }) .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 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 = {}; 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 }; } 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 = {}; 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 }; } }, { 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]; }, } );