feat(Center): 投资规划中心新建计划/复盘乐观更新

- planningSlice: 添加 optimisticAddEvent、replaceEvent、removeEvent reducers
- EventFormModal: 新建模式使用乐观更新,立即关闭弹窗显示数据
- account.js: Mock 数据按日期倒序排序,最新事件在前

乐观更新流程:
1. 创建临时事件(负数 ID)立即更新 UI
2. 后台发送 API 请求
3. 成功后替换为真实数据,失败则回滚

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-23 14:50:29 +08:00
parent ab5b19847f
commit d24f9c7b16
3 changed files with 97 additions and 10 deletions

View File

@@ -494,6 +494,13 @@ export const accountHandlers = [
}); });
} }
// 5. 按日期倒序排序(最新的在前面)
filteredEvents.sort((a, b) => {
const dateA = new Date(a.date || a.event_date);
const dateB = new Date(b.date || b.event_date);
return dateB - dateA; // 倒序:新日期在前
});
// 打印今天的事件(方便调试) // 打印今天的事件(方便调试)
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const todayEvents = filteredEvents.filter(e => const todayEvents = filteredEvents.filter(e =>

View File

@@ -196,6 +196,25 @@ const planningSlice = createSlice({
state.allEvents.push(action.payload); state.allEvents.push(action.payload);
state.lastUpdated = Date.now(); 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();
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@@ -251,7 +270,14 @@ const planningSlice = createSlice({
// ==================== 导出 ==================== // ==================== 导出 ====================
export const { clearEvents, setEvents, addEvent } = planningSlice.actions; export const {
clearEvents,
setEvents,
addEvent,
optimisticAddEvent,
replaceEvent,
removeEvent,
} = planningSlice.actions;
export default planningSlice.reducer; export default planningSlice.reducer;

View File

@@ -36,7 +36,12 @@ import 'dayjs/locale/zh-cn';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useAppDispatch } from '@/store/hooks'; import { useAppDispatch } from '@/store/hooks';
import { fetchAllEvents } from '@/store/slices/planningSlice'; import {
fetchAllEvents,
optimisticAddEvent,
replaceEvent,
removeEvent,
} from '@/store/slices/planningSlice';
import './EventFormModal.less'; import './EventFormModal.less';
import type { InvestmentEvent, EventType } from '@/types'; import type { InvestmentEvent, EventType } from '@/types';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@@ -276,7 +281,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
setStockOptions(options.length > 0 ? options : watchlistOptions); setStockOptions(options.length > 0 ? options : watchlistOptions);
}, [allStocks, watchlistOptions]); }, [allStocks, watchlistOptions]);
// 保存数据 // 保存数据(新建模式使用乐观更新)
const handleSave = useCallback(async (): Promise<void> => { const handleSave = useCallback(async (): Promise<void> => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
@@ -316,23 +321,72 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}` ? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
: `${base}/api/account/${apiEndpoint}`; : `${base}/api/account/${apiEndpoint}`;
const method = mode === 'edit' ? 'PUT' : 'POST'; // ===== 新建模式:乐观更新 =====
if (mode === 'create') {
const tempId = -Date.now(); // 负数临时 ID避免与服务器 ID 冲突
const tempEvent: InvestmentEvent = {
id: tempId,
title: values.title,
content: values.content || '',
description: values.content || '',
date: values.date.format('YYYY-MM-DD'),
event_date: values.date.format('YYYY-MM-DD'),
type: eventType,
stocks: stocksWithNames,
status: 'active',
source: 'user',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// ① 立即更新 UI
dispatch(optimisticAddEvent(tempEvent));
setSaving(false);
onClose(); // 立即关闭弹窗
// ② 后台发送 API 请求
try {
const response = await fetch(url, { const response = await fetch(url, {
method, method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', credentials: 'include',
}, body: JSON.stringify(requestData),
});
const data = await response.json();
if (response.ok && data.success) {
// ③ 用真实数据替换临时数据
dispatch(replaceEvent({ tempId, realEvent: data.data }));
logger.info('EventFormModal', `创建${label}成功`, { title: values.title });
message.success('添加成功');
onSuccess();
} else {
throw new Error(data.error || '创建失败');
}
} catch (error) {
// ④ 失败回滚
dispatch(removeEvent(tempId));
logger.error('EventFormModal', 'handleSave optimistic rollback', error);
message.error('创建失败,请重试');
}
return;
}
// ===== 编辑模式:传统更新 =====
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify(requestData), body: JSON.stringify(requestData),
}); });
if (response.ok) { if (response.ok) {
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, { logger.info('EventFormModal', `更新${label}成功`, {
itemId: editingEvent?.id, itemId: editingEvent?.id,
title: values.title, title: values.title,
}); });
message.success(mode === 'edit' ? '修改成功' : '添加成功'); message.success('修改成功');
onClose(); onClose();
onSuccess(); onSuccess();
// 使用 Redux 刷新数据,确保列表和日历同步 // 使用 Redux 刷新数据,确保列表和日历同步