302 lines
9.1 KiB
TypeScript
302 lines
9.1 KiB
TypeScript
/**
|
||
* planningSlice - 投资规划中心 Redux Slice
|
||
* 管理计划、复盘和日历事件数据
|
||
*/
|
||
|
||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||
import type { InvestmentEvent } from '@/types';
|
||
import { getApiBase } from '@/utils/apiConfig';
|
||
import { logger } from '@/utils/logger';
|
||
|
||
// ==================== State 类型定义 ====================
|
||
|
||
interface PlanningState {
|
||
/** 所有事件(计划 + 复盘 + 系统事件) */
|
||
allEvents: InvestmentEvent[];
|
||
/** 加载状态 */
|
||
loading: boolean;
|
||
/** 错误信息 */
|
||
error: string | null;
|
||
/** 最后更新时间 */
|
||
lastUpdated: number | null;
|
||
}
|
||
|
||
// ==================== 初始状态 ====================
|
||
|
||
const initialState: PlanningState = {
|
||
allEvents: [],
|
||
loading: false,
|
||
error: null,
|
||
lastUpdated: null,
|
||
};
|
||
|
||
// ==================== Async Thunks ====================
|
||
|
||
/**
|
||
* 加载所有事件数据
|
||
*/
|
||
export const fetchAllEvents = createAsyncThunk(
|
||
'planning/fetchAllEvents',
|
||
async (_, { rejectWithValue }) => {
|
||
try {
|
||
const base = getApiBase();
|
||
const response = await fetch(`${base}/api/account/calendar/events`, {
|
||
credentials: 'include',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
logger.debug('planningSlice', '数据加载成功', {
|
||
count: data.data?.length || 0,
|
||
});
|
||
return data.data || [];
|
||
} else {
|
||
throw new Error(data.error || '加载失败');
|
||
}
|
||
} catch (error) {
|
||
logger.error('planningSlice', 'fetchAllEvents', error);
|
||
return rejectWithValue(error instanceof Error ? error.message : '加载失败');
|
||
}
|
||
}
|
||
);
|
||
|
||
/**
|
||
* 创建计划/复盘
|
||
*/
|
||
export const createEvent = createAsyncThunk(
|
||
'planning/createEvent',
|
||
async (eventData: Partial<InvestmentEvent>, { rejectWithValue, dispatch }) => {
|
||
try {
|
||
const base = getApiBase();
|
||
const response = await fetch(`${base}/api/account/investment-plans`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify(eventData),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
logger.info('planningSlice', '创建成功', { title: eventData.title });
|
||
// 创建成功后重新加载所有数据
|
||
dispatch(fetchAllEvents());
|
||
return data.data;
|
||
} else {
|
||
throw new Error(data.error || '创建失败');
|
||
}
|
||
} catch (error) {
|
||
logger.error('planningSlice', 'createEvent', error);
|
||
return rejectWithValue(error instanceof Error ? error.message : '创建失败');
|
||
}
|
||
}
|
||
);
|
||
|
||
/**
|
||
* 更新计划/复盘
|
||
*/
|
||
export const updateEvent = createAsyncThunk(
|
||
'planning/updateEvent',
|
||
async ({ id, data }: { id: number; data: Partial<InvestmentEvent> }, { rejectWithValue, dispatch }) => {
|
||
try {
|
||
const base = getApiBase();
|
||
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
body: JSON.stringify(data),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
logger.info('planningSlice', '更新成功', { id });
|
||
// 更新成功后重新加载所有数据
|
||
dispatch(fetchAllEvents());
|
||
return result.data;
|
||
} else {
|
||
throw new Error(result.error || '更新失败');
|
||
}
|
||
} catch (error) {
|
||
logger.error('planningSlice', 'updateEvent', error);
|
||
return rejectWithValue(error instanceof Error ? error.message : '更新失败');
|
||
}
|
||
}
|
||
);
|
||
|
||
/**
|
||
* 删除计划/复盘
|
||
*/
|
||
export const deleteEvent = createAsyncThunk(
|
||
'planning/deleteEvent',
|
||
async (id: number, { rejectWithValue, dispatch }) => {
|
||
try {
|
||
const base = getApiBase();
|
||
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||
method: 'DELETE',
|
||
credentials: 'include',
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
logger.info('planningSlice', '删除成功', { id });
|
||
// 删除成功后重新加载所有数据
|
||
dispatch(fetchAllEvents());
|
||
return id;
|
||
} else {
|
||
throw new Error(data.error || '删除失败');
|
||
}
|
||
} catch (error) {
|
||
logger.error('planningSlice', 'deleteEvent', error);
|
||
return rejectWithValue(error instanceof Error ? error.message : '删除失败');
|
||
}
|
||
}
|
||
);
|
||
|
||
// ==================== Slice ====================
|
||
|
||
const planningSlice = createSlice({
|
||
name: 'planning',
|
||
initialState,
|
||
reducers: {
|
||
/** 清空数据 */
|
||
clearEvents: (state) => {
|
||
state.allEvents = [];
|
||
state.error = null;
|
||
},
|
||
/** 直接设置事件(用于乐观更新) */
|
||
setEvents: (state, action: PayloadAction<InvestmentEvent[]>) => {
|
||
state.allEvents = action.payload;
|
||
state.lastUpdated = Date.now();
|
||
},
|
||
/** 添加单个事件(乐观更新) */
|
||
addEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||
state.allEvents.push(action.payload);
|
||
state.lastUpdated = Date.now();
|
||
},
|
||
/** 乐观添加事件(插入到开头,使用临时 ID) */
|
||
optimisticAddEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||
state.allEvents.unshift(action.payload); // 新事件插入开头
|
||
state.lastUpdated = Date.now();
|
||
},
|
||
/** 替换临时事件为真实事件 */
|
||
replaceEvent: (state, action: PayloadAction<{ tempId: number; realEvent: InvestmentEvent }>) => {
|
||
const { tempId, realEvent } = action.payload;
|
||
const index = state.allEvents.findIndex(e => e.id === tempId);
|
||
if (index !== -1) {
|
||
state.allEvents[index] = realEvent;
|
||
}
|
||
state.lastUpdated = Date.now();
|
||
},
|
||
/** 移除事件(用于回滚) */
|
||
removeEvent: (state, action: PayloadAction<number>) => {
|
||
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
|
||
state.lastUpdated = Date.now();
|
||
},
|
||
/** 乐观更新事件(编辑时使用) */
|
||
optimisticUpdateEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||
const index = state.allEvents.findIndex(e => e.id === action.payload.id);
|
||
if (index !== -1) {
|
||
state.allEvents[index] = action.payload;
|
||
state.lastUpdated = Date.now();
|
||
}
|
||
},
|
||
},
|
||
extraReducers: (builder) => {
|
||
builder
|
||
// fetchAllEvents
|
||
.addCase(fetchAllEvents.pending, (state) => {
|
||
state.loading = true;
|
||
state.error = null;
|
||
})
|
||
.addCase(fetchAllEvents.fulfilled, (state, action) => {
|
||
state.loading = false;
|
||
state.allEvents = action.payload;
|
||
state.lastUpdated = Date.now();
|
||
})
|
||
.addCase(fetchAllEvents.rejected, (state, action) => {
|
||
state.loading = false;
|
||
state.error = action.payload as string;
|
||
})
|
||
// createEvent
|
||
.addCase(createEvent.pending, (state) => {
|
||
state.loading = true;
|
||
})
|
||
.addCase(createEvent.fulfilled, (state) => {
|
||
state.loading = false;
|
||
})
|
||
.addCase(createEvent.rejected, (state, action) => {
|
||
state.loading = false;
|
||
state.error = action.payload as string;
|
||
})
|
||
// updateEvent
|
||
.addCase(updateEvent.pending, (state) => {
|
||
state.loading = true;
|
||
})
|
||
.addCase(updateEvent.fulfilled, (state) => {
|
||
state.loading = false;
|
||
})
|
||
.addCase(updateEvent.rejected, (state, action) => {
|
||
state.loading = false;
|
||
state.error = action.payload as string;
|
||
})
|
||
// deleteEvent
|
||
.addCase(deleteEvent.pending, (state) => {
|
||
state.loading = true;
|
||
})
|
||
.addCase(deleteEvent.fulfilled, (state) => {
|
||
state.loading = false;
|
||
})
|
||
.addCase(deleteEvent.rejected, (state, action) => {
|
||
state.loading = false;
|
||
state.error = action.payload as string;
|
||
});
|
||
},
|
||
});
|
||
|
||
// ==================== 导出 ====================
|
||
|
||
export const {
|
||
clearEvents,
|
||
setEvents,
|
||
addEvent,
|
||
optimisticAddEvent,
|
||
replaceEvent,
|
||
removeEvent,
|
||
optimisticUpdateEvent,
|
||
} = planningSlice.actions;
|
||
|
||
export default planningSlice.reducer;
|
||
|
||
// ==================== Selectors ====================
|
||
|
||
export const selectAllEvents = (state: { planning: PlanningState }) => state.planning.allEvents;
|
||
export const selectPlanningLoading = (state: { planning: PlanningState }) => state.planning.loading;
|
||
export const selectPlanningError = (state: { planning: PlanningState }) => state.planning.error;
|
||
export const selectPlans = (state: { planning: PlanningState }) =>
|
||
state.planning.allEvents.filter(e => e.type === 'plan' && e.source !== 'future');
|
||
export const selectReviews = (state: { planning: PlanningState }) =>
|
||
state.planning.allEvents.filter(e => e.type === 'review' && e.source !== 'future');
|