Compare commits

...

12 Commits

Author SHA1 Message Date
zdl
e168e357d7 fix: CompactSearchBox 股票选择和行业筛选优化
- 股票选择后显示完整信息(代码+名称)而非仅代码
  - 行业筛选支持多选(用逗号分隔传给接口)
  - 新增 stockDisplayValueRef 缓存选中股票的显示值
2025-12-05 18:24:35 +08:00
zdl
61a5e56d15 fix: stocks 字段支持对象格式 {code, name}
- investment.ts: stocks 类型改为 Array<{code, name} | string>
  - EventFormModal: 编辑时兼容对象格式,保存时附带股票名称
2025-12-05 18:24:18 +08:00
zdl
957f6dd37e refactor: EventDetailCard 重命名为 EventCard,支持多变体模式
- 新增 EventCard.tsx 组件,支持 variant 属性(detail/compact)
  - 删除 EventDetailCard.tsx(功能已合并到 EventCard)
  - EventDetailModal 改用新的 EventCard 组件
2025-12-05 18:23:52 +08:00
zdl
cc7fdbff56 fix: UI调试 2025-12-05 18:04:28 +08:00
zdl
5eb7f97523 fix: 修复 key 重复 2025-12-05 17:29:58 +08:00
zdl
380b3189f5 feat: 修复数据结构访问 2025-12-05 17:29:32 +08:00
zdl
15487a8307 refactor: EventFormModal 从 Chakra UI 迁移到 Ant Design
- 使用 Ant Design Form/Modal/Select 组件
 - 简化字段: 标题、日期、内容、关联股票
 - 新增计划/复盘模板系统
 - 股票选择支持前端模糊搜索 + 自选股快捷选择
 - 新增响应式样式 (EventFormModal.less)
 - EventPanel: 移除不再需要的 props
2025-12-05 17:24:06 +08:00
zdl
b74d88e592 fix: 适配 watchlist 新数据结构
- CompactSearchBox: 改用 Redux 获取股票列表
 - useWatchlist: 适配 { stock_code, stock_name }[] 结构
 - Center: 修复 watchlist key + H5 评论 Badge 溢出
2025-12-05 17:23:51 +08:00
zdl
e8a9a6f180 refactor: 股票数据管理迁移到 Redux,新增类型化 Hooks
- stockSlice: 新增 loadAllStocks action(带缓存检查)
 - stockSlice: watchlist 结构升级为 { stock_code, stock_name }[]
 - store/hooks.ts: 新增 useAppDispatch, useAppSelector 类型化 hooks
 - stockService: 移除 getAllStocks(已迁移到 Redux)
 - mock: 股票搜索支持模糊匹配 + 相关性排序
2025-12-05 17:21:36 +08:00
zdl
74eae630dd fix: 修复ts报错 2025-12-05 15:38:42 +08:00
zdl
5358303db0 feat: 添加mock数据 2025-12-05 15:28:15 +08:00
zdl
a36ae5323e style: EventFormModal 和 InvestmentCalendar H5 响应式适配 2025-12-05 15:09:14 +08:00
24 changed files with 1281 additions and 992 deletions

View File

@@ -1,56 +0,0 @@
// src/components/StockChart/types.ts
// 图表弹窗共享类型和常量
/**
* 股票信息(两个组件共用)
*/
export interface StockInfo {
stock_code: string;
stock_name?: string;
}
/**
* 图表颜色常量
*/
export const CHART_COLORS = {
background: '#1a1a1a',
border: '#404040',
text: '#e0e0e0',
textSecondary: '#999',
gridLine: '#2a2a2a',
up: '#ef5350', // 涨
down: '#26a69a', // 跌
accent: '#ffd700', // 金色强调
avgLine: '#ffa726', // 均价线
priceLine: '#2196f3', // 价格线
} as const;
/**
* Modal 样式常量
*/
export const MODAL_STYLES = {
border: '2px solid #ffd700',
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
borderRadius: { mobile: '12px', desktop: '8px' },
maxHeight: '85vh',
width: { mobile: '96vw', desktop: '90vw' },
} as const;
/**
* Tooltip 样式
*/
export const TOOLTIP_STYLES = {
backgroundColor: 'rgba(30, 30, 30, 0.95)',
borderColor: '#404040',
borderWidth: 1,
textStyle: { color: '#e0e0e0' },
} as const;
/**
* 轴线样式
*/
export const AXIS_STYLES = {
lineColor: '#404040',
labelColor: '#999',
fontSize: { mobile: 10, desktop: 12 },
} as const;

View File

@@ -873,14 +873,17 @@ export function generateMockEvents(params = {}) {
filteredEvents = filteredEvents.filter(e => filteredEvents = filteredEvents.filter(e =>
e.title.toLowerCase().includes(query) || e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) || e.description.toLowerCase().includes(query) ||
e.keywords.some(k => k.toLowerCase().includes(query)) // keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
); );
} }
// 行业筛选 // 行业筛选
if (industry_code) { if (industry_code) {
filteredEvents = filteredEvents.filter(e => filteredEvents = filteredEvents.filter(e =>
e.industry.includes(industry_code) || e.keywords.includes(industry_code) e.industry.includes(industry_code) ||
// keywords 是对象数组 { concept, ... },需要检查 concept 属性
e.keywords.some(k => k.concept && k.concept.includes(industry_code))
); );
} }
@@ -893,9 +896,11 @@ export function generateMockEvents(params = {}) {
return false; return false;
} }
// 检查事件的 related_stocks 中是否包含该股票代码 // 检查事件的 related_stocks 中是否包含该股票代码
return e.related_stocks.some(code => { // related_stocks 是对象数组 { stock_code, stock_name, ... }
const cleanCode = code.replace(/\.(SH|SZ)$/, ''); return e.related_stocks.some(stock => {
return cleanCode === cleanStockCode || code === stock_code; const stockCodeStr = stock.stock_code || '';
const cleanCode = stockCodeStr.replace(/\.(SH|SZ)$/, '');
return cleanCode === cleanStockCode || stockCodeStr === stock_code;
}); });
}); });
} }

View File

@@ -434,13 +434,8 @@ export const accountHandlers = [
http.get('/api/account/calendar/events', async ({ request }) => { http.get('/api/account/calendar/events', async ({ request }) => {
await delay(NETWORK_DELAY); await delay(NETWORK_DELAY);
const currentUser = getCurrentUser(); // Mock 模式下允许无登录访问,使用默认用户 id: 1
if (!currentUser) { const currentUser = getCurrentUser() || { id: 1 };
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const url = new URL(request.url); const url = new URL(request.url);
const startDate = url.searchParams.get('start_date'); const startDate = url.searchParams.get('start_date');
@@ -455,8 +450,8 @@ export const accountHandlers = [
} }
// 2. 获取投资计划和复盘,转换为日历事件格式 // 2. 获取投资计划和复盘,转换为日历事件格式
// Mock 模式:不过滤 user_id显示所有 mock 数据(方便开发测试)
const investmentPlansAsEvents = mockInvestmentPlans const investmentPlansAsEvents = mockInvestmentPlans
.filter(plan => plan.user_id === currentUser.id)
.map(plan => ({ .map(plan => ({
id: plan.id, id: plan.id,
user_id: plan.user_id, user_id: plan.user_id,
@@ -489,10 +484,13 @@ export const accountHandlers = [
}); });
} }
console.log('[Mock] 合并后的日历事件数量:', { console.log('[Mock] 日历事件详情:', {
currentUserId: currentUser.id,
calendarEvents: calendarEvents.length, calendarEvents: calendarEvents.length,
investmentPlansAsEvents: investmentPlansAsEvents.length, investmentPlansAsEvents: investmentPlansAsEvents.length,
total: filteredEvents.length total: filteredEvents.length,
plansCount: filteredEvents.filter(e => e.type === 'plan').length,
reviewsCount: filteredEvents.filter(e => e.type === 'review').length
}); });
return HttpResponse.json({ return HttpResponse.json({

View File

@@ -123,12 +123,12 @@ const generateStockList = () => {
// 股票相关的 Handlers // 股票相关的 Handlers
export const stockHandlers = [ export const stockHandlers = [
// 搜索股票(个股中心页面使用) // 搜索股票(个股中心页面使用)- 支持模糊搜索
http.get('/api/stocks/search', async ({ request }) => { http.get('/api/stocks/search', async ({ request }) => {
await delay(200); await delay(200);
const url = new URL(request.url); const url = new URL(request.url);
const query = url.searchParams.get('q') || ''; const query = (url.searchParams.get('q') || '').toLowerCase().trim();
const limit = parseInt(url.searchParams.get('limit') || '10'); const limit = parseInt(url.searchParams.get('limit') || '10');
console.log('[Mock Stock] 搜索股票:', { query, limit }); console.log('[Mock Stock] 搜索股票:', { query, limit });
@@ -136,22 +136,44 @@ export const stockHandlers = [
const stocks = generateStockList(); const stocks = generateStockList();
// 如果没有搜索词,返回空结果 // 如果没有搜索词,返回空结果
if (!query.trim()) { if (!query) {
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
data: [] data: []
}); });
} }
// 过滤匹配的股票 // 模糊搜索:代码 + 名称(不区分大小写)
const results = stocks.filter(s => const results = stocks.filter(s => {
s.code.includes(query) || s.name.includes(query) const code = s.code.toLowerCase();
).slice(0, limit); const name = s.name.toLowerCase();
return code.includes(query) || name.includes(query);
});
// 按相关性排序:完全匹配 > 开头匹配 > 包含匹配
results.sort((a, b) => {
const aCode = a.code.toLowerCase();
const bCode = b.code.toLowerCase();
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
// 计算匹配分数
const getScore = (code, name) => {
if (code === query || name === query) return 100; // 完全匹配
if (code.startsWith(query)) return 80; // 代码开头
if (name.startsWith(query)) return 60; // 名称开头
if (code.includes(query)) return 40; // 代码包含
if (name.includes(query)) return 20; // 名称包含
return 0;
};
return getScore(bCode, bName) - getScore(aCode, aName);
});
// 返回格式化数据 // 返回格式化数据
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
data: results.map(s => ({ data: results.slice(0, limit).map(s => ({
stock_code: s.code, stock_code: s.code,
stock_name: s.name, stock_name: s.name,
market: s.code.startsWith('6') ? 'SH' : 'SZ', market: s.code.startsWith('6') ? 'SH' : 'SZ',

View File

@@ -1,52 +1,11 @@
// src/services/stockService.js // src/services/stockService.js
// 股票数据服务 // 股票数据服务 - 模糊搜索工具函数
// 注意: getAllStocks 已迁移到 Redux (stockSlice.loadAllStocks)
import { logger } from '../utils/logger';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
/** /**
* 股票数据服务 * 股票数据服务
*/ */
export const stockService = { export const stockService = {
/**
* 获取所有股票列表
* @returns {Promise<{success: boolean, data: Array<{code: string, name: string}>}>}
*/
async getAllStocks() {
try {
const response = await fetch(`${API_BASE_URL}/api/stocklist`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
logger.debug('stockService', 'getAllStocks 成功', {
count: data?.length || 0
});
return {
success: true,
data: data || []
};
} catch (error) {
logger.error('stockService', 'getAllStocks', error);
return {
success: false,
data: [],
error: error.message
};
}
},
/** /**
* 模糊搜索股票(匹配 code 或 name * 模糊搜索股票(匹配 code 或 name
* @param {string} query - 搜索关键词 * @param {string} query - 搜索关键词

15
src/store/hooks.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Redux Typed Hooks
* 提供类型安全的 useDispatch 和 useSelector hooks
*/
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import { store } from './index';
// 从 store 推断类型
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// 类型化的 hooks
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -63,4 +63,9 @@ export const injectReducer = (key, reducer) => {
store.replaceReducer(createRootReducer()); store.replaceReducer(createRootReducer());
}; };
/**
* @typedef {typeof store.dispatch} AppDispatch
* @typedef {ReturnType<typeof store.getState>} RootState
*/
export default store; export default store;

View File

@@ -152,11 +152,11 @@ export const fetchExpectationScore = createAsyncThunk(
); );
/** /**
* 加载用户自选股列表 * 加载用户自选股列表(包含完整信息)
*/ */
export const loadWatchlist = createAsyncThunk( export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist', 'stock/loadWatchlist',
async (_, { getState }) => { async () => {
logger.debug('stockSlice', 'loadWatchlist'); logger.debug('stockSlice', 'loadWatchlist');
try { try {
@@ -167,11 +167,15 @@ export const loadWatchlist = createAsyncThunk(
const data = await response.json(); const data = await response.json();
if (data.success && data.data) { if (data.success && data.data) {
const stockCodes = data.data.map(item => item.stock_code); // 返回完整的股票信息,而不仅仅是 stock_code
const watchlistData = data.data.map(item => ({
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
logger.debug('stockSlice', '自选股列表加载成功', { logger.debug('stockSlice', '自选股列表加载成功', {
count: stockCodes.length count: watchlistData.length
}); });
return stockCodes; return watchlistData;
} }
return []; return [];
@@ -182,6 +186,43 @@ export const loadWatchlist = createAsyncThunk(
} }
); );
/**
* 加载全部股票列表(用于前端模糊搜索)
*/
export const loadAllStocks = createAsyncThunk(
'stock/loadAllStocks',
async (_, { getState }) => {
// 检查缓存
const cached = getState().stock.allStocks;
if (cached && cached.length > 0) {
logger.debug('stockSlice', 'allStocks 缓存命中', { count: cached.length });
return cached;
}
logger.debug('stockSlice', 'loadAllStocks');
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/stocklist`, {
credentials: 'include'
});
const data = await response.json();
if (Array.isArray(data)) {
logger.debug('stockSlice', '全部股票列表加载成功', {
count: data.length
});
return data;
}
return [];
} catch (error) {
logger.error('stockSlice', 'loadAllStocks', error);
return [];
}
}
);
/** /**
* 切换自选股状态 * 切换自选股状态
*/ */
@@ -219,7 +260,7 @@ export const toggleWatchlist = createAsyncThunk(
throw new Error(data.error || '操作失败'); throw new Error(data.error || '操作失败');
} }
return { stockCode, isInWatchlist }; return { stockCode, stockName, isInWatchlist };
} }
); );
@@ -246,9 +287,12 @@ const stockSlice = createSlice({
// 超预期得分缓存 { [eventId]: score } // 超预期得分缓存 { [eventId]: score }
expectationScores: {}, expectationScores: {},
// 自选股列表 Set<stockCode> // 自选股列表 [{ stock_code, stock_name }]
watchlist: [], watchlist: [],
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
allStocks: [],
// 加载状态 // 加载状态
loading: { loading: {
stocks: false, stocks: false,
@@ -256,7 +300,8 @@ const stockSlice = createSlice({
eventDetail: false, eventDetail: false,
historicalEvents: false, historicalEvents: false,
chainAnalysis: false, chainAnalysis: false,
watchlist: false watchlist: false,
allStocks: false
}, },
// 错误信息 // 错误信息
@@ -383,16 +428,29 @@ const stockSlice = createSlice({
state.loading.watchlist = false; state.loading.watchlist = false;
}) })
// ===== loadAllStocks =====
.addCase(loadAllStocks.pending, (state) => {
state.loading.allStocks = true;
})
.addCase(loadAllStocks.fulfilled, (state, action) => {
state.allStocks = action.payload;
state.loading.allStocks = false;
})
.addCase(loadAllStocks.rejected, (state) => {
state.loading.allStocks = false;
})
// ===== toggleWatchlist ===== // ===== toggleWatchlist =====
.addCase(toggleWatchlist.fulfilled, (state, action) => { .addCase(toggleWatchlist.fulfilled, (state, action) => {
const { stockCode, isInWatchlist } = action.payload; const { stockCode, stockName, isInWatchlist } = action.payload;
if (isInWatchlist) { if (isInWatchlist) {
// 移除 // 移除
state.watchlist = state.watchlist.filter(code => code !== stockCode); state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
} else { } else {
// 添加 // 添加
if (!state.watchlist.includes(stockCode)) { const exists = state.watchlist.some(item => item.stock_code === stockCode);
state.watchlist.push(stockCode); if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName });
} }
} }
}); });

View File

@@ -46,8 +46,8 @@ export interface InvestmentEvent {
/** 重要度 (1-5) */ /** 重要度 (1-5) */
importance?: number; importance?: number;
/** 相关股票代码列表 */ /** 相关股票列表 */
stocks?: string[]; stocks?: Array<{ code: string; name: string } | string>;
/** 标签列表 */ /** 标签列表 */
tags?: string[]; tags?: string[];
@@ -85,8 +85,8 @@ export interface PlanFormData {
/** 事件类型 */ /** 事件类型 */
type: EventType; type: EventType;
/** 相关股票代码列表 */ /** 相关股票列表 */
stocks: string[]; stocks: Array<{ code: string; name: string } | string>;
/** 标签列表 */ /** 标签列表 */
tags: string[]; tags: string[];

View File

@@ -14,6 +14,7 @@ import dayjs from 'dayjs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice'; import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
import { loadAllStocks } from '@store/slices/stockSlice';
import { stockService } from '@services/stockService'; import { stockService } from '@services/stockService';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import TradingTimeFilter from './TradingTimeFilter'; import TradingTimeFilter from './TradingTimeFilter';
@@ -61,9 +62,12 @@ const CompactSearchBox = ({
const dispatch = useDispatch(); const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData); const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading); const industryLoading = useSelector(selectIndustryLoading);
const reduxAllStocks = useSelector((state) => state.stock.allStocks);
// 防抖搜索 // 防抖搜索
const debouncedSearchRef = useRef(null); const debouncedSearchRef = useRef(null);
// 存储股票选择时的显示值(代码+名称),用于 useEffect 同步时显示完整信息
const stockDisplayValueRef = useRef(null);
const triggerSearch = useCallback((params) => { const triggerSearch = useCallback((params) => {
logger.debug('CompactSearchBox', '触发搜索', { params }); logger.debug('CompactSearchBox', '触发搜索', { params });
@@ -82,16 +86,19 @@ const CompactSearchBox = ({
}; };
}, [triggerSearch]); }, [triggerSearch]);
// 加载股票数据 // 加载股票数据(从 Redux 获取)
useEffect(() => { useEffect(() => {
const loadStocks = async () => { if (!reduxAllStocks || reduxAllStocks.length === 0) {
const response = await stockService.getAllStocks(); dispatch(loadAllStocks());
if (response.success && response.data) { }
setAllStocks(response.data); }, [dispatch, reduxAllStocks]);
}
}; // 同步 Redux 数据到本地状态
loadStocks(); useEffect(() => {
}, []); if (reduxAllStocks && reduxAllStocks.length > 0) {
setAllStocks(reduxAllStocks);
}
}, [reduxAllStocks]);
// 预加载行业数据(解决第一次点击无数据问题) // 预加载行业数据(解决第一次点击无数据问题)
useEffect(() => { useEffect(() => {
@@ -143,9 +150,17 @@ const CompactSearchBox = ({
} }
if (filters.q) { if (filters.q) {
setInputValue(filters.q); // 如果是股票选择触发的搜索,使用存储的显示值(代码+名称)
if (stockDisplayValueRef.current && stockDisplayValueRef.current.code === filters.q) {
setInputValue(stockDisplayValueRef.current.displayValue);
} else {
setInputValue(filters.q);
// 清除已失效的显示值缓存
stockDisplayValueRef.current = null;
}
} else if (!filters.q) { } else if (!filters.q) {
setInputValue(''); setInputValue('');
stockDisplayValueRef.current = null;
} }
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days; const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
@@ -228,7 +243,7 @@ const CompactSearchBox = ({
sort: actualSort, sort: actualSort,
importance: importanceValue, importance: importanceValue,
q: (overrides.q ?? filters.q) ?? '', q: (overrides.q ?? filters.q) ?? '',
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''), industry_code: overrides.industry_code ?? (industryValue?.join(',') || ''),
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''), start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''), end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''), recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
@@ -264,10 +279,13 @@ const CompactSearchBox = ({
}); });
} }
setInputValue(`${stockInfo.code} ${stockInfo.name}`); const displayValue = `${stockInfo.code} ${stockInfo.name}`;
setInputValue(displayValue);
// 存储显示值,供 useEffect 同步时使用
stockDisplayValueRef.current = { code: stockInfo.code, displayValue };
const params = buildFilterParams({ const params = buildFilterParams({
q: stockInfo.code, q: stockInfo.code, // 接口只传代码
industry_code: '' industry_code: ''
}); });
triggerSearch(params); triggerSearch(params);
@@ -330,7 +348,7 @@ const CompactSearchBox = ({
} }
const params = buildFilterParams({ const params = buildFilterParams({
industry_code: value?.[value.length - 1] || '' industry_code: value?.join(',') || ''
}); });
triggerSearch(params); triggerSearch(params);
}; };

View File

@@ -469,13 +469,13 @@ const FlowingConcepts = () => {
const row3 = concepts.slice(20, 30); const row3 = concepts.slice(20, 30);
// 渲染单个概念卡片 // 渲染单个概念卡片
const renderConceptCard = (concept, globalIdx) => { const renderConceptCard = (concept, globalIdx, uniqueIdx) => {
const colors = getColor(concept.change_pct); const colors = getColor(concept.change_pct);
const isActive = hoveredIdx === globalIdx; const isActive = hoveredIdx === globalIdx;
return ( return (
<Box <Box
key={globalIdx} key={`${globalIdx}-${uniqueIdx}`}
flexShrink={0} flexShrink={0}
px={3} px={3}
py={2} py={2}
@@ -582,7 +582,7 @@ const FlowingConcepts = () => {
> >
{/* 复制两份实现无缝滚动 */} {/* 复制两份实现无缝滚动 */}
{[...items, ...items].map((concept, idx) => {[...items, ...items].map((concept, idx) =>
renderConceptCard(concept, startIdx + (idx % items.length)) renderConceptCard(concept, startIdx + (idx % items.length), idx)
)} )}
</Flex> </Flex>
</Box> </Box>

View File

@@ -1,9 +1,9 @@
// src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js // src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js
import { useSelector, useDispatch, shallowEqual } from 'react-redux'; import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useEffect, useCallback, useMemo } from 'react'; import { useEffect, useCallback, useMemo } from 'react';
import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../../../../store/slices/stockSlice'; import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '@store/slices/stockSlice';
import { message } from 'antd'; import { message } from 'antd';
import { logger } from '../../../../../utils/logger'; import { logger } from '@utils/logger';
/** /**
* 标准化股票代码为6位格式 * 标准化股票代码为6位格式
@@ -41,8 +41,9 @@ export const useWatchlist = (shouldLoad = true) => {
const loading = useSelector(state => state.stock.loading.watchlist); const loading = useSelector(state => state.stock.loading.watchlist);
// 转换为 Set 方便快速查询标准化为6位代码 // 转换为 Set 方便快速查询标准化为6位代码
// 注意: watchlistArray 现在是 { stock_code, stock_name }[] 格式
const watchlistSet = useMemo(() => { const watchlistSet = useMemo(() => {
return new Set(watchlistArray.map(normalizeStockCode)); return new Set(watchlistArray.map(item => normalizeStockCode(item.stock_code)));
}, [watchlistArray]); }, [watchlistArray]);
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时) // 初始化时加载自选股列表(只在 shouldLoad 为 true 时)

View File

@@ -321,7 +321,7 @@ export default function CenterDashboard() {
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
{watchlist.slice(0, 10).map((stock) => ( {watchlist.slice(0, 10).map((stock) => (
<LinkBox <LinkBox
key={stock.id} key={stock.stock_code}
p={3} p={3}
borderRadius="md" borderRadius="md"
_hover={{ bg: hoverBg }} _hover={{ bg: hoverBg }}
@@ -568,15 +568,22 @@ export default function CenterDashboard() {
<Text fontSize="sm" noOfLines={3}> <Text fontSize="sm" noOfLines={3}>
{comment.content} {comment.content}
</Text> </Text>
<HStack justify="space-between" fontSize="xs" color={secondaryText}> <HStack justify="space-between" fontSize="xs" color={secondaryText} spacing={2}>
<HStack> <HStack flexShrink={0}>
<Icon as={FiClock} /> <Icon as={FiClock} />
<Text>{formatDate(comment.created_at)}</Text> <Text>{formatDate(comment.created_at)}</Text>
</HStack> </HStack>
{comment.event_title && ( {comment.event_title && (
<Tooltip label={comment.event_title}> <Tooltip label={comment.event_title}>
<Badge variant="subtle" fontSize="xs"> <Badge
{comment.event_title.slice(0, 20)}... variant="subtle"
fontSize="xs"
maxW={{ base: '120px', md: '180px' }}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{comment.event_title}
</Badge> </Badge>
</Tooltip> </Tooltip>
)} )}

View File

@@ -26,6 +26,7 @@ import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext'; import { usePlanningData } from './PlanningContext';
import { EventDetailModal } from './EventDetailModal'; import { EventDetailModal } from './EventDetailModal';
import type { InvestmentEvent } from '@/types'; import type { InvestmentEvent } from '@/types';
import './InvestmentCalendar.less';
// 懒加载投资日历组件 // 懒加载投资日历组件
const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar')); const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar'));
@@ -108,14 +109,37 @@ export const CalendarPanel: React.FC = () => {
return ( return (
<Box> <Box>
<Box height={{ base: '380px', md: '560px' }}> <Box
height={{ base: '380px', md: '560px' }}
sx={{
// FullCalendar 按钮样式覆盖(与日历视图按钮颜色一致)
'.fc .fc-button': {
backgroundColor: '#805AD5 !important',
borderColor: '#805AD5 !important',
color: '#fff !important',
'&:hover': {
backgroundColor: '#6B46C1 !important',
borderColor: '#6B46C1 !important',
},
'&:disabled': {
backgroundColor: '#6B46C1 !important',
borderColor: '#6B46C1 !important',
opacity: '1 !important',
},
},
// 今天日期高亮边框
'.fc-daygrid-day.fc-day-today': {
border: '2px solid #805AD5 !important',
},
}}
>
<FullCalendar <FullCalendar
plugins={[dayGridPlugin, interactionPlugin]} plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth" initialView="dayGridMonth"
locale="zh-cn" locale="zh-cn"
headerToolbar={{ headerToolbar={{
left: 'prev,next', left: 'prev,next today',
center: 'today', center: 'title',
right: '' right: ''
}} }}
events={calendarEvents} events={calendarEvents}

View File

@@ -0,0 +1,296 @@
/**
* EventCard - 统一的投资事件卡片组件
*
* 通过 variant 属性控制两种显示模式:
* - list: 列表模式EventPanel 中使用,带编辑/删除按钮)
* - detail: 详情模式(日历弹窗中使用,显示类型徽章)
*
* 两种模式都支持:
* - 标题显示
* - 描述内容展开/收起
* - 股票标签显示
*/
import React, { useState, useEffect, useRef, memo } from 'react';
import {
Box,
Badge,
IconButton,
Flex,
Card,
CardBody,
VStack,
HStack,
Text,
Icon,
Tag,
TagLabel,
TagLeftIcon,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import {
FiEdit2,
FiTrash2,
FiFileText,
FiCalendar,
FiTrendingUp,
FiChevronDown,
FiChevronUp,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import type { InvestmentEvent, EventType, EventSource } from '@/types';
dayjs.locale('zh-cn');
/**
* 卡片变体类型
*/
export type EventCardVariant = 'list' | 'detail';
/**
* 类型信息接口
*/
interface TypeInfo {
color: string;
text: string;
}
/**
* 获取类型信息
*/
const getTypeInfo = (type?: EventType, source?: EventSource): TypeInfo => {
if (source === 'future') {
return { color: 'blue', text: '系统事件' };
}
if (type === 'plan') {
return { color: 'purple', text: '我的计划' };
}
if (type === 'review') {
return { color: 'green', text: '我的复盘' };
}
return { color: 'gray', text: '未知类型' };
};
/**
* EventCard Props
*/
export interface EventCardProps {
/** 事件数据 */
event: InvestmentEvent;
/** 卡片变体: list(列表模式) | detail(详情模式) */
variant?: EventCardVariant;
/** 主题颜色list 模式) */
colorScheme?: string;
/** 显示标签(用于 aria-label */
label?: string;
/** 主要文本颜色 */
textColor?: string;
/** 次要文本颜色 */
secondaryText?: string;
/** 卡片背景色list 模式) */
cardBg?: string;
/** 边框颜色detail 模式) */
borderColor?: string;
/** 编辑回调list 模式) */
onEdit?: (event: InvestmentEvent) => void;
/** 删除回调list 模式) */
onDelete?: (id: number) => void;
}
/** 描述最大显示行数 */
const MAX_LINES = 3;
/**
* EventCard 组件
*/
export const EventCard = memo<EventCardProps>(({
event,
variant = 'list',
colorScheme = 'purple',
label = '事件',
textColor,
secondaryText,
cardBg,
borderColor,
onEdit,
onDelete,
}) => {
// 展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflow, setIsOverflow] = useState(false);
const descriptionRef = useRef<HTMLParagraphElement>(null);
// 默认颜色值(使用 hooks
const defaultTextColor = useColorModeValue('gray.700', 'white');
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
const defaultCardBg = useColorModeValue('gray.50', 'gray.700');
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
// 使用传入的值或默认值
const finalTextColor = textColor || defaultTextColor;
const finalSecondaryText = secondaryText || defaultSecondaryText;
const finalCardBg = cardBg || defaultCardBg;
const finalBorderColor = borderColor || defaultBorderColor;
// 获取描述内容
const description = event.description || event.content || '';
// 检测描述是否溢出
useEffect(() => {
const el = descriptionRef.current;
if (el && description) {
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
const maxHeight = lineHeight * MAX_LINES;
setIsOverflow(el.scrollHeight > maxHeight + 5);
} else {
setIsOverflow(false);
}
}, [description]);
// 获取类型信息
const typeInfo = getTypeInfo(event.type, event.source);
// 是否为 list 模式
const isListMode = variant === 'list';
// 渲染容器
const renderContainer = (children: React.ReactNode) => {
if (isListMode) {
return (
<Card
bg={finalCardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody p={{ base: 2, md: 3 }}>
{children}
</CardBody>
</Card>
);
}
return (
<Box
p={{ base: 3, md: 4 }}
borderRadius="md"
border="1px"
borderColor={finalBorderColor}
>
{children}
</Box>
);
};
return renderContainer(
<VStack align="stretch" spacing={{ base: 2, md: 3 }}>
{/* 头部区域:标题 + 徽章 + 操作按钮 */}
<Flex justify="space-between" align="start" gap={{ base: 1, md: 2 }}>
<VStack align="start" spacing={1} flex={1}>
{/* 标题行 */}
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
<Icon as={FiFileText} color={`${colorScheme}.500`} boxSize={{ base: 4, md: 5 }} />
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
{event.title}
</Text>
{/* detail 模式显示类型徽章 */}
{!isListMode && (
<Badge colorScheme={typeInfo.color} variant="subtle" fontSize={{ base: 'xs', md: 'sm' }}>
{typeInfo.text}
</Badge>
)}
</HStack>
{/* list 模式显示日期 */}
{isListMode && (
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap">
<Icon as={FiCalendar} boxSize={{ base: 2.5, md: 3 }} color={finalSecondaryText} />
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
</Text>
</HStack>
)}
</VStack>
{/* list 模式显示编辑/删除按钮 */}
{isListMode && (onEdit || onDelete) && (
<HStack spacing={{ base: 0, md: 1 }}>
{onEdit && (
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => onEdit(event)}
aria-label={`编辑${label}`}
/>
)}
{onDelete && (
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => onDelete(event.id)}
aria-label={`删除${label}`}
/>
)}
</HStack>
)}
</Flex>
{/* 描述内容(可展开/收起) */}
{description && (
<Box>
<Text
ref={descriptionRef}
fontSize={{ base: 'xs', md: 'sm' }}
color={finalTextColor}
noOfLines={isExpanded ? undefined : MAX_LINES}
whiteSpace="pre-wrap"
>
{description}
</Text>
{isOverflow && (
<Button
size="xs"
variant="ghost"
colorScheme={colorScheme}
mt={1}
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
{/* 股票标签 */}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
<Text fontSize={{ base: 'xs', md: 'sm' }} color={finalSecondaryText}>
:
</Text>
{event.stocks.map((stock, idx) => {
// 兼容两种格式:对象 {code, name} 或字符串
const stockCode = typeof stock === 'string' ? stock : stock.code;
const stockName = typeof stock === 'string' ? stock : stock.name;
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
return (
<Tag key={stockCode || idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{displayText}</TagLabel>
</Tag>
);
})}
</HStack>
)}
</VStack>
);
});
EventCard.displayName = 'EventCard';
export default EventCard;

View File

@@ -1,145 +0,0 @@
/**
* EventDetailCard - 事件详情卡片组件
* 用于日历视图中展示单个事件的详细信息
*/
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Badge,
Flex,
HStack,
Text,
Tag,
TagLabel,
TagLeftIcon,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import {
FiTrendingUp,
FiChevronDown,
FiChevronUp,
} from 'react-icons/fi';
import type { InvestmentEvent } from '@/types';
/**
* EventDetailCard Props
*/
export interface EventDetailCardProps {
/** 事件数据 */
event: InvestmentEvent;
/** 边框颜色 */
borderColor?: string;
/** 次要文字颜色 */
secondaryText?: string;
}
/**
* 最大显示行数
*/
const MAX_LINES = 3;
/**
* EventDetailCard 组件
*/
export const EventDetailCard: React.FC<EventDetailCardProps> = ({
event,
borderColor: borderColorProp,
secondaryText: secondaryTextProp,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflow, setIsOverflow] = useState(false);
const descriptionRef = useRef<HTMLParagraphElement>(null);
// 默认颜色
const defaultBorderColor = useColorModeValue('gray.200', 'gray.600');
const defaultSecondaryText = useColorModeValue('gray.600', 'gray.400');
const borderColor = borderColorProp || defaultBorderColor;
const secondaryText = secondaryTextProp || defaultSecondaryText;
// 检测内容是否溢出
useEffect(() => {
const el = descriptionRef.current;
if (el) {
// 计算行高和最大高度
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
const maxHeight = lineHeight * MAX_LINES;
setIsOverflow(el.scrollHeight > maxHeight + 5); // 5px 容差
}
}, [event.description]);
// 获取事件类型标签
const getEventBadge = () => {
if (event.source === 'future') {
return <Badge colorScheme="blue" variant="subtle"></Badge>;
} else if (event.type === 'plan') {
return <Badge colorScheme="purple" variant="subtle"></Badge>;
} else if (event.type === 'review') {
return <Badge colorScheme="green" variant="subtle"></Badge>;
}
return null;
};
return (
<Box
p={{ base: 3, md: 4 }}
borderRadius="md"
border="1px"
borderColor={borderColor}
>
{/* 标题和标签 */}
<Flex justify="space-between" align="start" mb={{ base: 1, md: 2 }} gap={{ base: 1, md: 2 }}>
<HStack flexWrap="wrap" flex={1} spacing={{ base: 1, md: 2 }} gap={1}>
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
{event.title}
</Text>
{getEventBadge()}
</HStack>
</Flex>
{/* 描述内容 - 支持展开/收起 */}
{event.description && (
<Box mb={{ base: 1, md: 2 }}>
<Text
ref={descriptionRef}
fontSize={{ base: 'xs', md: 'sm' }}
color={secondaryText}
noOfLines={isExpanded ? undefined : MAX_LINES}
whiteSpace="pre-wrap"
>
{event.description}
</Text>
{isOverflow && (
<Button
size="xs"
variant="link"
colorScheme="blue"
mt={1}
onClick={() => setIsExpanded(!isExpanded)}
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
{/* 相关股票 */}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
<Text fontSize={{ base: 'xs', md: 'sm' }} color={secondaryText}>:</Text>
{event.stocks.map((stock, i) => (
<Tag key={i} size="sm" colorScheme="blue" mb={1}>
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
)}
</Box>
);
};
export default EventDetailCard;

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { Modal, Space } from 'antd'; import { Modal, Space } from 'antd';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { EventDetailCard } from './EventDetailCard'; import { EventCard } from './EventCard';
import { EventEmptyState } from './EventEmptyState'; import { EventEmptyState } from './EventEmptyState';
import type { InvestmentEvent } from '@/types'; import type { InvestmentEvent } from '@/types';
@@ -82,9 +82,10 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
) : ( ) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}> <Space direction="vertical" size={12} style={{ width: '100%' }}>
{events.map((event, idx) => ( {events.map((event, idx) => (
<EventDetailCard <EventCard
key={idx} key={idx}
event={event} event={event}
variant="detail"
borderColor={borderColor} borderColor={borderColor}
secondaryText={secondaryText} secondaryText={secondaryText}
/> />

View File

@@ -0,0 +1,198 @@
/* EventFormModal.less - 投资计划/复盘弹窗响应式样式 */
// ==================== 变量定义 ====================
@mobile-breakpoint: 768px;
@modal-border-radius-mobile: 12px;
@modal-border-radius-desktop: 8px;
// 间距
@spacing-xs: 4px;
@spacing-sm: 8px;
@spacing-md: 12px;
@spacing-lg: 16px;
@spacing-xl: 20px;
@spacing-xxl: 24px;
// 字体大小
@font-size-xs: 12px;
@font-size-sm: 14px;
@font-size-md: 16px;
// 颜色
@color-border: #f0f0f0;
@color-text-secondary: #999;
@color-error: #ff4d4f;
// ==================== 主样式 ====================
.event-form-modal {
// Modal 整体
.ant-modal-content {
border-radius: @modal-border-radius-desktop;
}
// Modal 标题放大加粗
.ant-modal-title {
font-size: 20px;
font-weight: 700;
}
.ant-modal-body {
padding: @spacing-xxl;
padding-top: 36px; // 增加标题与表单间距
}
.ant-form-item {
margin-bottom: @spacing-xl;
}
// 表单标签加粗,左对齐
.ant-form-item-label {
text-align: left !important;
> label {
font-weight: 600 !important;
color: #333;
}
}
// 字符计数样式
.ant-input-textarea-show-count::after {
font-size: @font-size-xs;
color: @color-text-secondary;
}
// 日期选择器全宽
.ant-picker {
width: 100%;
}
// 股票标签样式
.ant-tag {
margin: 2px;
border-radius: @spacing-xs;
}
// 模板按钮组
.template-buttons {
.ant-btn {
font-size: @font-size-xs;
}
}
// 底部操作栏布局
.modal-footer {
display: flex;
justify-content: flex-end;
}
// 加载状态
.ant-btn-loading {
opacity: 0.8;
}
// 错误状态动画
.ant-form-item-has-error {
.ant-input,
.ant-picker,
.ant-select-selector {
animation: shake 0.3s ease-in-out;
}
}
}
// ==================== 移动端适配 ====================
@media (max-width: @mobile-breakpoint) {
.event-form-modal {
// Modal 整体尺寸
.ant-modal {
width: calc(100vw - 32px) !important;
max-width: 100% !important;
margin: @spacing-lg auto;
top: 0;
padding-bottom: 0;
}
.ant-modal-content {
max-height: calc(100vh - 32px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: @modal-border-radius-mobile;
}
// Modal 头部
.ant-modal-header {
padding: @spacing-md @spacing-lg;
flex-shrink: 0;
}
.ant-modal-title {
font-size: @font-size-md;
}
// Modal 内容区域
.ant-modal-body {
padding: @spacing-lg;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
// Modal 底部
.ant-modal-footer {
padding: @spacing-md @spacing-lg;
flex-shrink: 0;
border-top: 1px solid @color-border;
}
// 表单项间距
.ant-form-item {
margin-bottom: @spacing-lg;
}
// 表单标签
.ant-form-item-label > label {
font-size: @font-size-sm;
height: auto;
}
// 输入框字体 - iOS 防止缩放需要 16px
.ant-input,
.ant-picker-input > input,
.ant-select-selection-search-input {
font-size: @font-size-md !important;
}
// 文本域高度
.ant-input-textarea textarea {
font-size: @font-size-md !important;
min-height: 120px;
}
// 模板按钮组
.template-buttons .ant-btn {
font-size: @font-size-xs;
padding: 2px @spacing-sm;
height: 26px;
}
// 股票选择器
.ant-select-selector {
min-height: 40px !important;
}
// 底部按钮
.ant-modal-footer .ant-btn {
font-size: @font-size-md;
height: 40px;
border-radius: @spacing-sm;
}
}
}
// ==================== 动画 ====================
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}

View File

@@ -1,66 +1,135 @@
/** /**
* EventFormModal - 通用事件表单弹窗组件 * EventFormModal - 通用事件表单弹窗组件 (Ant Design 重构版)
* 用于新建/编辑投资计划、复盘、日历事件等 * 用于新建/编辑投资计划、复盘
* *
* 通过 props 配置差异化行为 * 功能特性
* - 字段显示控制(日期选择器、类型、状态、重要度、标签等) * - 使用 Ant Design 组件
* - API 端点配置investment-plans 或 calendar/events * - 简化字段:标题、日期、描述、关联股票
* - 主题颜色和标签文案 * - 计划/复盘模板系统
* - 股票多选组件带智能搜索
* - Ctrl + Enter 快捷键保存
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { import {
Button,
Modal, Modal,
ModalOverlay, Form,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Icon,
Input, Input,
InputGroup, DatePicker,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select, Select,
Button,
Tag, Tag,
TagLabel, Divider,
TagLeftIcon, message,
TagCloseButton, Space,
} from '@chakra-ui/react'; Spin,
} from 'antd';
import type { SelectProps } from 'antd';
import { import {
FiSave, BulbOutlined,
FiCalendar, StarOutlined,
FiTrendingUp, } from '@ant-design/icons';
FiHash,
} from 'react-icons/fi';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { useSelector } from 'react-redux';
import { useAppDispatch } from '@/store/hooks';
import { usePlanningData } from './PlanningContext'; import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, EventType, EventStatus } from '@/types'; import './EventFormModal.less';
import type { InvestmentEvent, EventType } from '@/types';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig'; import { getApiBase } from '@/utils/apiConfig';
import { loadWatchlist, loadAllStocks } from '@/store/slices/stockSlice';
import { stockService } from '@/services/stockService';
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
const { TextArea } = Input;
/**
* 股票选项接口
*/
interface StockOption {
value: string;
label: string;
stock_code: string;
stock_name: string;
}
/** /**
* 表单数据接口 * 表单数据接口
*/ */
interface FormData { interface FormData {
date: string;
title: string; title: string;
date: dayjs.Dayjs;
content: string; content: string;
type: EventType;
stocks: string[]; stocks: string[];
tags: string[]; }
status: EventStatus;
importance: number; /**
* 模板类型
*/
interface Template {
label: string;
content: string;
}
/**
* 计划模板
*/
const PLAN_TEMPLATES: Template[] = [
{
label: '目标',
content: '【投资目标】\n\n',
},
{
label: '策略',
content: '【交易策略】\n\n',
},
{
label: '风险控制',
content: '【风险控制】\n- 止损位:\n- 仓位控制:\n',
},
{
label: '时间规划',
content: '【时间规划】\n- 建仓时机:\n- 持仓周期:\n',
},
];
/**
* 复盘模板
*/
const REVIEW_TEMPLATES: Template[] = [
{
label: '操作回顾',
content: '【操作回顾】\n- 买入操作:\n- 卖出操作:\n',
},
{
label: '盈亏分析',
content: '【盈亏分析】\n- 盈亏金额:\n- 收益率:\n- 主要原因:\n',
},
{
label: '经验总结',
content: '【经验总结】\n- 做对的地方:\n- 做错的地方:\n',
},
{
label: '后续调整',
content: '【后续调整】\n- 策略调整:\n- 仓位调整:\n',
},
];
/**
* Redux state 类型
*/
interface RootState {
stock: {
watchlist: Array<{ stock_code: string; stock_name: string }>;
allStocks: Array<{ code: string; name: string }>;
loading: {
watchlist: boolean;
allStocks: boolean;
};
};
} }
/** /**
@@ -112,95 +181,134 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
initialDate, initialDate,
editingEvent, editingEvent,
onSuccess, onSuccess,
colorScheme = 'purple',
label = '事件', label = '事件',
showDatePicker = true,
showTypeSelect = false,
showStatusSelect = true,
showImportance = false,
showTags = true,
stockInputMode = 'tag',
apiEndpoint = 'investment-plans', apiEndpoint = 'investment-plans',
}) => { }) => {
const { toast, secondaryText } = usePlanningData(); const { loadAllData } = usePlanningData();
const dispatch = useAppDispatch();
const [form] = Form.useForm<FormData>();
const [saving, setSaving] = useState(false);
const [stockOptions, setStockOptions] = useState<StockOption[]>([]);
const [searchText, setSearchText] = useState('');
const modalContentRef = useRef<HTMLDivElement>(null);
// 表单数据 // 从 Redux 获取自选股和全部股票列
const [formData, setFormData] = useState<FormData>({ const watchlist = useSelector((state: RootState) => state.stock.watchlist);
date: initialDate || dayjs().format('YYYY-MM-DD'), const allStocks = useSelector((state: RootState) => state.stock.allStocks);
title: '', const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist);
content: '', const allStocksLoading = useSelector((state: RootState) => state.stock.loading.allStocks);
type: eventType,
stocks: [],
tags: [],
status: 'active',
importance: 3,
});
// 股票和标签输入框 // 将自选股转换为 StockOption 格式
const [stockInput, setStockInput] = useState<string>(''); const watchlistOptions = useMemo<StockOption[]>(() => {
const [tagInput, setTagInput] = useState<string>(''); return watchlist.map(item => ({
value: item.stock_code,
label: `${item.stock_name}(${item.stock_code})`,
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
}, [watchlist]);
// 保存中状态 // 获取模板列表
const [saving, setSaving] = useState<boolean>(false); const templates = eventType === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES;
// 生成默认模板内容
const getDefaultContent = (type: EventType): string => {
const templateList = type === 'plan' ? PLAN_TEMPLATES : REVIEW_TEMPLATES;
return templateList.map(t => t.content).join('\n');
};
// 弹窗打开时加载数据
useEffect(() => {
if (isOpen) {
// 加载自选股列表
dispatch(loadWatchlist());
// 加载全部股票列表(用于模糊搜索)
dispatch(loadAllStocks());
}
}, [isOpen, dispatch]);
// 初始化表单数据 // 初始化表单数据
useEffect(() => { useEffect(() => {
if (mode === 'edit' && editingEvent) { if (isOpen) {
setFormData({ if (mode === 'edit' && editingEvent) {
date: dayjs(editingEvent.event_date || editingEvent.date).format('YYYY-MM-DD'), // 将 stocks 转换为代码数组(兼容对象和字符串格式)
title: editingEvent.title, const stockCodes = (editingEvent.stocks || []).map(stock =>
content: editingEvent.description || editingEvent.content || '', typeof stock === 'string' ? stock : stock.code
type: editingEvent.type || eventType, );
stocks: editingEvent.stocks || [], form.setFieldsValue({
tags: editingEvent.tags || [], title: editingEvent.title,
status: editingEvent.status || 'active', date: dayjs(editingEvent.event_date || editingEvent.date),
importance: editingEvent.importance || 3, content: editingEvent.description || editingEvent.content || '',
}); stocks: stockCodes,
// 如果是文本模式,将股票数组转为逗号分隔 });
if (stockInputMode === 'text' && editingEvent.stocks) { } else {
setStockInput(editingEvent.stocks.join(',')); // 新建模式,重置表单并预填充模板内容
form.resetFields();
form.setFieldsValue({
date: initialDate ? dayjs(initialDate) : dayjs(),
stocks: [],
content: getDefaultContent(eventType),
});
} }
} else {
// 新建模式,重置表单
setFormData({
date: initialDate || dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: eventType,
stocks: [],
tags: [],
status: 'active',
importance: 3,
});
setStockInput('');
setTagInput('');
} }
}, [mode, editingEvent, eventType, initialDate, stockInputMode]); }, [isOpen, mode, editingEvent, initialDate, form, eventType]);
// 股票搜索(前端模糊搜索)
const handleStockSearch = useCallback((value: string) => {
setSearchText(value);
if (!value || value.length < 1) {
// 无搜索词时显示自选股列表
setStockOptions(watchlistOptions);
return;
}
// 使用 stockService.fuzzySearch 进行前端模糊搜索
const results = stockService.fuzzySearch(value, allStocks, 10);
const options: StockOption[] = results.map(stock => ({
value: stock.code,
label: `${stock.name}(${stock.code})`,
stock_code: stock.code,
stock_name: stock.name,
}));
setStockOptions(options.length > 0 ? options : watchlistOptions);
}, [allStocks, watchlistOptions]);
// 保存数据 // 保存数据
const handleSave = async (): Promise<void> => { const handleSave = useCallback(async (): Promise<void> => {
try { try {
const values = await form.validateFields();
setSaving(true); setSaving(true);
const base = getApiBase(); const base = getApiBase();
// 构建请求数据 // 将选中的股票代码转换为包含名称的对象数组
let requestData: Record<string, unknown> = { ...formData }; const stocksWithNames = (values.stocks || []).map((code: string) => {
const stockInfo = allStocks.find(s => s.code === code);
const watchlistInfo = watchlist.find(s => s.stock_code === code);
return {
code,
name: stockInfo?.name || watchlistInfo?.stock_name || code,
};
});
// 如果是文本模式,解析股票输入 // 构建请求数据
if (stockInputMode === 'text' && stockInput) { const requestData: Record<string, unknown> = {
requestData.stocks = stockInput.split(',').map(s => s.trim()).filter(s => s); title: values.title,
} content: values.content,
date: values.date.format('YYYY-MM-DD'),
type: eventType,
stocks: stocksWithNames,
status: 'active',
};
// 根据 API 端点调整字段名 // 根据 API 端点调整字段名
if (apiEndpoint === 'calendar/events') { if (apiEndpoint === 'calendar/events') {
requestData = { requestData.description = values.content;
title: formData.title, requestData.event_date = values.date.format('YYYY-MM-DD');
description: formData.content, delete requestData.content;
type: formData.type, delete requestData.date;
importance: formData.importance,
stocks: requestData.stocks,
event_date: formData.date,
};
} }
const url = mode === 'edit' && editingEvent const url = mode === 'edit' && editingEvent
@@ -221,274 +329,247 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
if (response.ok) { if (response.ok) {
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, { logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
itemId: editingEvent?.id, itemId: editingEvent?.id,
title: formData.title, title: values.title,
});
toast({
title: mode === 'edit' ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
}); });
message.success(mode === 'edit' ? '修改成功' : '添加成功');
onClose(); onClose();
onSuccess(); onSuccess();
loadAllData();
} else { } else {
throw new Error('保存失败'); throw new Error('保存失败');
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message !== '保存失败') {
// 表单验证错误,不显示额外提示
return;
}
logger.error('EventFormModal', 'handleSave', error, { logger.error('EventFormModal', 'handleSave', error, {
itemId: editingEvent?.id, itemId: editingEvent?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
}); });
message.error('保存失败,请稍后重试');
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; }, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
// 添加股票Tag 模式) // 监听键盘快捷键 Ctrl + Enter
const handleAddStock = (): void => { useEffect(() => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) { const handleKeyDown = (e: KeyboardEvent) => {
setFormData({ if (isOpen && (e.ctrlKey || e.metaKey) && e.key === 'Enter') {
...formData, e.preventDefault();
stocks: [...formData.stocks, stockInput.trim()], handleSave();
}); }
setStockInput(''); };
}
};
// 移除股票Tag 模式) window.addEventListener('keydown', handleKeyDown);
const handleRemoveStock = (index: number): void => { return () => window.removeEventListener('keydown', handleKeyDown);
setFormData({ }, [isOpen, handleSave]);
...formData,
stocks: formData.stocks.filter((_, i) => i !== index),
});
};
// 添加标签 // 插入模板
const handleAddTag = (): void => { const handleInsertTemplate = (template: Template): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) { const currentContent = form.getFieldValue('content') || '';
setFormData({ const newContent = currentContent
...formData, ? `${currentContent}\n\n${template.content}`
tags: [...formData.tags, tagInput.trim()], : template.content;
}); form.setFieldsValue({ content: newContent });
setTagInput('');
}
};
// 移除标签
const handleRemoveTag = (index: number): void => {
setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== index),
});
}; };
// 获取标题 placeholder // 获取标题 placeholder
const getTitlePlaceholder = (): string => { const getTitlePlaceholder = (): string => {
switch (formData.type) { if (eventType === 'plan') {
case 'plan': return '例如关注AI板块';
return '例如:布局新能源板块';
case 'review':
return '例如:本周操作复盘';
case 'reminder':
return '例如:关注半导体板块';
case 'analysis':
return '例如:行业分析任务';
default:
return '请输入标题';
} }
return '例如12月操作总结';
}; };
// 获取内容 placeholder // 获取内容 placeholder
const getContentPlaceholder = (): string => { const getContentPlaceholder = (): string => {
switch (formData.type) { if (eventType === 'plan') {
case 'plan': return '计划模板:\n目标:\n策略:\n风险控制:\n时间规划:';
return '详细描述您的投资计划...';
case 'review':
return '详细记录您的投资复盘...';
default:
return '详细描述...';
} }
return '复盘模板:\n操作回顾:\n盈亏分析:\n经验总结:\n后续调整:';
};
// 判断是否显示自选股列表
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
// 股票选择器选项配置
const selectProps: SelectProps<string[]> = {
mode: 'multiple',
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
filterOption: false,
onSearch: handleStockSearch,
loading: watchlistLoading || allStocksLoading,
notFoundContent: allStocksLoading ? (
<div style={{ textAlign: 'center', padding: '8px' }}>
<Spin size="small" />
<span style={{ marginLeft: 8 }}>...</span>
</div>
) : '暂无结果',
options: stockOptions,
onFocus: () => {
if (stockOptions.length === 0) {
setStockOptions(watchlistOptions);
}
},
tagRender: (props) => {
const { label: tagLabel, closable, onClose: onTagClose } = props;
return (
<Tag
color="blue"
closable={closable}
onClose={onTagClose}
style={{ marginRight: 3 }}
>
{tagLabel}
</Tag>
);
},
popupRender: (menu) => (
<>
{isShowingWatchlist && watchlistOptions.length > 0 && (
<>
<div style={{ padding: '4px 8px 0' }}>
<span style={{ fontSize: 12, color: '#999' }}>
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
</span>
</div>
<Divider style={{ margin: '4px 0 0' }} />
</>
)}
{menu}
{!isShowingWatchlist && searchText && (
<>
<Divider style={{ margin: '8px 0' }} />
<div style={{ padding: '0 8px 4px' }}>
<span style={{ fontSize: 12, color: '#999' }}>
<BulbOutlined style={{ marginRight: 4 }} />
</span>
</div>
</>
)}
</>
),
};
// 获取按钮文案
const getButtonText = (): string => {
if (mode === 'edit') {
return eventType === 'plan' ? '更新计划' : '更新复盘';
}
return eventType === 'plan' ? '创建计划' : '创建复盘';
}; };
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}> <Modal
<ModalOverlay /> title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
<ModalContent> open={isOpen}
<ModalHeader> onCancel={onClose}
{mode === 'edit' ? '编辑' : '新建'}{label} width={600}
</ModalHeader> destroyOnClose
<ModalCloseButton /> maskClosable={true}
<ModalBody> keyboard
<VStack spacing={4}> className="event-form-modal"
{/* 日期选择器 */} footer={
{showDatePicker && ( <div className="modal-footer">
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
)}
{/* 标题 */}
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder={getTitlePlaceholder()}
/>
</FormControl>
{/* 内容/描述 */}
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder={getContentPlaceholder()}
rows={6}
/>
</FormControl>
{/* 类型选择 */}
{showTypeSelect && (
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as EventType })}
>
<option value="plan"></option>
<option value="review"></option>
<option value="reminder"></option>
<option value="analysis"></option>
</Select>
</FormControl>
)}
{/* 状态选择 */}
{showStatusSelect && (
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
)}
{/* 重要度选择 */}
{showImportance && (
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.importance}
onChange={(e) => setFormData({ ...formData, importance: parseInt(e.target.value) })}
>
<option value={5}> </option>
<option value={4}> </option>
<option value={3}> </option>
<option value={2}> </option>
<option value={1}> </option>
</Select>
</FormControl>
)}
{/* 股票输入 - Tag 模式 */}
{stockInputMode === 'tag' && (
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{formData.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton onClick={() => handleRemoveStock(idx)} />
</Tag>
))}
</HStack>
</FormControl>
)}
{/* 股票输入 - 文本模式 */}
{stockInputMode === 'text' && (
<FormControl>
<FormLabel></FormLabel>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="例如600519,000858,002415"
/>
</FormControl>
)}
{/* 标签输入 */}
{showTags && (
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{formData.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme={colorScheme}>
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton onClick={() => handleRemoveTag(idx)} />
</Tag>
))}
</HStack>
</FormControl>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button <Button
colorScheme={colorScheme} type="primary"
onClick={handleSave} onClick={handleSave}
isDisabled={!formData.title || (showDatePicker && !formData.date)} loading={saving}
isLoading={saving} disabled={saving}
leftIcon={<FiSave />}
> >
{getButtonText()}
</Button> </Button>
</ModalFooter> </div>
</ModalContent> }
>
<div ref={modalContentRef}>
{isOpen && <Form
form={form}
layout="horizontal"
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
labelAlign="left"
requiredMark={false}
initialValues={{
date: dayjs(),
stocks: [],
}}
>
{/* 标题 */}
<Form.Item
name="title"
label={<span style={{ fontWeight: 600 }}> <span style={{ color: '#ff4d4f' }}>*</span></span>}
rules={[
{ required: true, message: '请输入标题' },
{ max: 50, message: '标题不能超过50个字符' },
]}
>
<Input
placeholder={getTitlePlaceholder()}
maxLength={50}
showCount
/>
</Form.Item>
{/* 日期 */}
<Form.Item
name="date"
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
placeholder="选择日期"
allowClear={false}
/>
</Form.Item>
{/* 描述/内容 - 上下布局 */}
<Form.Item
name="content"
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
rules={[{ required: true, message: '请输入内容' }]}
labelCol={{ span: 24 }}
wrapperCol={{ span: 24 }}
style={{ marginBottom: 8 }}
>
<TextArea
placeholder={getContentPlaceholder()}
rows={8}
showCount
maxLength={2000}
style={{ resize: 'vertical' }}
/>
</Form.Item>
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
<div style={{ marginBottom: 24 }}>
<Space wrap size="small" className="template-buttons">
{templates.map((template) => (
<Button
key={template.label}
size="small"
onClick={() => handleInsertTemplate(template)}
>
{template.label}
</Button>
))}
</Space>
</div>
{/* 关联股票 */}
<Form.Item
name="stocks"
label={<span style={{ fontWeight: 600 }}></span>}
>
<Select {...selectProps} />
</Form.Item>
</Form>}
</div>
</Modal> </Modal>
); );
}; };

View File

@@ -8,185 +8,25 @@
* - label: 显示文案 * - label: 显示文案
*/ */
import React, { useState, useEffect, useRef, memo, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { import {
Box, Box,
Badge,
IconButton,
Flex,
Grid, Grid,
Card,
CardBody,
VStack, VStack,
HStack,
Text, Text,
Spinner, Spinner,
Center, Center,
Icon, Icon,
Tag,
TagLabel,
TagLeftIcon,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import { FiFileText } from 'react-icons/fi';
FiEdit2,
FiTrash2,
FiFileText,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext'; import { usePlanningData } from './PlanningContext';
import { EventFormModal } from './EventFormModal'; import { EventFormModal } from './EventFormModal';
import type { InvestmentEvent, EventStatus } from '@/types'; import { EventCard } from './EventCard';
import type { InvestmentEvent } from '@/types';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig'; import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* 获取状态信息
*/
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
/**
* EventCard Props
*/
interface EventCardProps {
item: InvestmentEvent;
colorScheme: string;
label: string;
textColor: string;
secondaryText: string;
cardBg: string;
onEdit: (item: InvestmentEvent) => void;
onDelete: (id: number) => void;
}
/**
* EventCard 组件(使用 memo 优化渲染性能)
*/
const EventCard = memo<EventCardProps>(({
item,
colorScheme,
label,
textColor,
secondaryText,
cardBg,
onEdit,
onDelete,
}) => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody p={{ base: 2, md: 3 }}>
<VStack align="stretch" spacing={{ base: 2, md: 3 }}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack spacing={{ base: 1, md: 2 }}>
<Icon as={FiFileText} color={`${colorScheme}.500`} boxSize={{ base: 4, md: 5 }} />
<Text fontWeight="bold" fontSize={{ base: 'md', md: 'lg' }}>
{item.title}
</Text>
</HStack>
<HStack spacing={{ base: 1, md: 2 }}>
<Icon as={FiCalendar} boxSize={{ base: 2.5, md: 3 }} color={secondaryText} />
<Text fontSize={{ base: 'xs', md: 'sm' }} color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
fontSize={{ base: 'xs', md: 'sm' }}
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack spacing={{ base: 0, md: 1 }}>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => onEdit(item)}
aria-label={`编辑${label}`}
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => onDelete(item.id)}
aria-label={`删除${label}`}
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize={{ base: 'xs', md: 'sm' }} color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={{ base: 1, md: 2 }} flexWrap="wrap" gap={1}>
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme={colorScheme} variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
});
EventCard.displayName = 'EventCard';
/** /**
* EventPanel Props * EventPanel Props
*/ */
@@ -314,7 +154,8 @@ export const EventPanel: React.FC<EventPanelProps> = ({
{events.map(event => ( {events.map(event => (
<EventCard <EventCard
key={event.id} key={event.id}
item={event} event={event}
variant="list"
colorScheme={colorScheme} colorScheme={colorScheme}
label={label} label={label}
textColor={textColor} textColor={textColor}
@@ -336,14 +177,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
eventType={type} eventType={type}
editingEvent={editingItem} editingEvent={editingItem}
onSuccess={loadAllData} onSuccess={loadAllData}
colorScheme={colorScheme}
label={label} label={label}
showDatePicker={true}
showTypeSelect={false}
showStatusSelect={true}
showImportance={false}
showTags={true}
stockInputMode="tag"
apiEndpoint="investment-plans" apiEndpoint="investment-plans"
/> />
</Box> </Box>

View File

@@ -1,156 +0,0 @@
/* src/views/Dashboard/components/InvestmentCalendar.css */
/* FullCalendar 自定义样式 */
.fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
.fc-theme-standard td,
.fc-theme-standard th {
border-color: #e2e8f0;
}
.fc-button-primary {
background-color: #3182ce !important;
border-color: #3182ce !important;
}
.fc-button-primary:hover {
background-color: #2c5282 !important;
border-color: #2c5282 !important;
}
.fc-button-primary:not(:disabled):active,
.fc-button-primary:not(:disabled).fc-button-active {
background-color: #2c5282 !important;
border-color: #2c5282 !important;
}
.fc-daygrid-day-number {
color: #2d3748;
font-weight: 500;
}
.fc-daygrid-day.fc-day-today {
background-color: #e6f3ff !important;
}
.fc-event {
border: none;
padding: 2px 4px;
font-size: 12px;
font-weight: 500;
}
.fc-daygrid-event-dot {
display: none;
}
.fc-daygrid-day-events {
margin-top: 2px;
}
/* 响应式调整 - H5 优化 */
@media (max-width: 768px) {
.fc-toolbar {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
padding: 0 4px;
}
.fc-toolbar-chunk {
display: flex;
justify-content: center;
}
.fc-toolbar-title {
font-size: 1.1rem !important;
margin: 4px 0;
order: 3;
width: 100%;
text-align: center;
}
.fc-button-group {
margin: 0;
}
.fc-button {
padding: 4px 8px !important;
font-size: 12px !important;
}
.fc-today-button {
padding: 4px 12px !important;
}
/* 日历头部星期 */
.fc-col-header-cell-cushion {
font-size: 12px;
padding: 4px 2px;
}
/* 日期数字 */
.fc-daygrid-day-number {
font-size: 13px;
padding: 2px 4px;
}
/* 事件标签 */
.fc-event {
font-size: 10px !important;
padding: 1px 2px;
margin: 1px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.fc-event-title {
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
}
/* 日期单元格 */
.fc-daygrid-day {
min-height: 60px !important;
}
.fc-daygrid-day-frame {
min-height: 60px !important;
}
.fc-daygrid-day-events {
margin-top: 1px;
}
/* 更多链接 */
.fc-daygrid-more-link {
font-size: 10px;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
.fc-theme-standard td,
.fc-theme-standard th {
border-color: #4a5568;
}
.fc-daygrid-day-number {
color: #e2e8f0;
}
.fc-daygrid-day.fc-day-today {
background-color: #2d3748 !important;
}
.fc-col-header-cell-cushion,
.fc-daygrid-day-number {
color: #e2e8f0;
}
}

View File

@@ -0,0 +1,123 @@
// src/views/Dashboard/components/InvestmentCalendar.less
// 颜色变量(与日历视图按钮一致的紫色)
@primary-color: #805AD5;
@primary-hover: #6B46C1;
@border-color: #e2e8f0;
@text-color: #2d3748;
@today-bg: #e6f3ff;
// 暗色模式颜色
@dark-border-color: #4a5568;
@dark-text-color: #e2e8f0;
@dark-today-bg: #2d3748;
// FullCalendar 自定义样式
.fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
// 工具栏按钮紧密排列(提升优先级)
.fc .fc-toolbar.fc-header-toolbar {
justify-content: flex-start !important;
gap: 8px !important;
}
.fc .fc-toolbar-chunk:first-child {
display: flex !important;
gap: 4px !important;
}
.fc-theme-standard {
td, th {
border-color: @border-color;
}
}
// 按钮样式(针对 fc-button-group 内的按钮)
.fc .fc-toolbar .fc-button-group .fc-button {
background-color: @primary-color !important;
border-color: @primary-color !important;
color: #fff !important;
&:hover {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
}
&:not(:disabled):active,
&:not(:disabled).fc-button-active {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
}
}
// 今天按钮样式
.fc .fc-toolbar .fc-today-button {
background-color: @primary-color !important;
border-color: @primary-color !important;
color: #fff !important;
&:hover {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
}
// 选中状态disabled 表示当前视图包含今天)
&:disabled {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
opacity: 1 !important;
color: #fff !important;
}
}
// 日期数字
.fc-daygrid-day-number {
color: @text-color;
font-weight: 500;
}
// 今天高亮
.fc-daygrid-day.fc-day-today {
background-color: @today-bg !important;
border: 2px solid @primary-color !important;
}
// 事件样式
.fc-event {
border: none;
padding: 2px 4px;
font-size: 12px;
font-weight: 500;
}
.fc-daygrid-event-dot {
display: none;
}
.fc-daygrid-day-events {
margin-top: 2px;
}
// 暗色模式支持
@media (prefers-color-scheme: dark) {
.fc-theme-standard {
td, th {
border-color: @dark-border-color;
}
}
.fc-daygrid-day-number {
color: @dark-text-color;
}
.fc-daygrid-day.fc-day-today {
background-color: @dark-today-bg !important;
}
.fc-col-header-cell-cushion,
.fc-daygrid-day-number {
color: @dark-text-color;
}
}

View File

@@ -58,7 +58,7 @@ import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal'; import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../components/StockChart/KLineChartModal'; import KLineChartModal from '../../../components/StockChart/KLineChartModal';
import './InvestmentCalendar.css'; import './InvestmentCalendar.less';
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');

View File

@@ -46,7 +46,7 @@ import { PlanningDataProvider } from './PlanningContext';
import type { InvestmentEvent, PlanningContextValue } from '@/types'; import type { InvestmentEvent, PlanningContextValue } from '@/types';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig'; import { getApiBase } from '@/utils/apiConfig';
import './InvestmentCalendar.css'; import './InvestmentCalendar.less';
// 懒加载子面板组件(实现代码分割) // 懒加载子面板组件(实现代码分割)
const CalendarPanel = lazy(() => const CalendarPanel = lazy(() =>
@@ -186,22 +186,23 @@ const InvestmentPlanningCenter: React.FC = () => {
colorScheme="purple" colorScheme="purple"
size={{ base: 'sm', md: 'md' }} size={{ base: 'sm', md: 'md' }}
> >
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} wrap="wrap" gap={2}> <Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
<TabList mb={0} borderBottom="none"> <TabList mb={0} borderBottom="none" flex="1" minW={0}>
<Tab fontSize={{ base: 'xs', md: 'sm' }} px={{ base: 2, md: 4 }}> <Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
<Icon as={FiTarget} mr={{ base: 1, md: 2 }} boxSize={{ base: 3, md: 4 }} /> <Icon as={FiTarget} mr={1} boxSize={{ base: 3, md: 4 }} />
({planCount}) ({planCount})
</Tab> </Tab>
<Tab fontSize={{ base: 'xs', md: 'sm' }} px={{ base: 2, md: 4 }}> <Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
<Icon as={FiFileText} mr={{ base: 1, md: 2 }} boxSize={{ base: 3, md: 4 }} /> <Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
({reviewCount}) ({reviewCount})
</Tab> </Tab>
</TabList> </TabList>
<Button <Button
size={{ base: 'xs', md: 'sm' }} size="xs"
colorScheme="purple" colorScheme="purple"
leftIcon={<Icon as={FiPlus} boxSize={{ base: 3, md: 4 }} />} leftIcon={<Icon as={FiPlus} boxSize={3} />}
fontSize={{ base: 'xs', md: 'sm' }} fontSize={{ base: '11px', md: 'sm' }}
flexShrink={0}
onClick={() => { onClick={() => {
if (listTab === 0) { if (listTab === 0) {
setOpenPlanModalTrigger(prev => prev + 1); setOpenPlanModalTrigger(prev => prev + 1);