Files
digital-pilates/store/checkinSlice.ts
richarjiang e3e2f1b8c6 feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
2025-08-14 09:57:13 +08:00

211 lines
8.0 KiB
TypeScript

import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, updateCheckin } from '@/services/checkins';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type CheckinExercise = {
key: string;
name: string;
category: string;
sets: number; // 组数
reps?: number; // 每组重复(计次型)
durationSec?: number; // 每组时长(计时型)
completed?: boolean; // 是否已完成该动作
};
export type CheckinRecord = {
id: string;
date: string; // YYYY-MM-DD
items: CheckinExercise[];
note?: string;
};
export type CheckinState = {
byDate: Record<string, CheckinRecord>;
currentDate: string | null;
monthLoaded: Record<string, boolean>; // 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<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);
},
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);
},
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].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<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;
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,
};
})
.addCase(loadMonthCheckins.fulfilled, (state, action) => {
const monthKey = action.payload.monthKey;
const merged = action.payload.byDate;
for (const d of Object.keys(merged)) {
state.byDate[d] = merged[d];
}
state.monthLoaded[monthKey] = true;
});
},
});
export const { setCurrentDate, addExercise, removeExercise, 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 list = await fetchDailyCheckins(date);
return { date, 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];
},
}
);