862 lines
44 KiB
TypeScript
862 lines
44 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { ChevronRight, ChevronDown, BarChart3, PieChart, Globe, TrendingUp, LayoutTemplate, Save, FileText, ArrowDownRight, GripVertical, X, ChevronsLeft, ArrowUpRight, Pencil, Settings, Star, Plus } from 'lucide-react';
|
||
import { StockChart } from '../components/StockChart';
|
||
import { STOCK_DATA_CONFIG } from '../data/mockData';
|
||
|
||
// --- Mock Data Generators ---
|
||
// 已移至 data/mockData.ts,这里保留引用
|
||
|
||
// Define initial data with Icons attached
|
||
const INITIAL_DATA = [
|
||
{
|
||
...STOCK_DATA_CONFIG.valuation,
|
||
icon: <BarChart3 size={18} />,
|
||
tags: ['A股市场', '估值'],
|
||
description: '从估值水平、历史分位、质量溢价等多角度评估当前估值合理性',
|
||
},
|
||
{
|
||
...STOCK_DATA_CONFIG.macro,
|
||
icon: <Globe size={18} />,
|
||
tags: ['A股市场', '宏观'],
|
||
description: '结合宏观经济环境、无风险利率、风险溢价等因素分析投资胜率',
|
||
},
|
||
{
|
||
...STOCK_DATA_CONFIG.assets,
|
||
icon: <TrendingUp size={18} />,
|
||
tags: ['A股市场', '基本面'],
|
||
description: '跟踪产业链上下游走势、竞争格局变化等基本面相关资产表现',
|
||
},
|
||
{
|
||
...STOCK_DATA_CONFIG.capital,
|
||
icon: <PieChart size={18} />,
|
||
tags: ['A股市场', '资金面'],
|
||
description: '观察市场资金流向、成交活跃度等资金面动态变化',
|
||
}
|
||
];
|
||
|
||
// 格式化摘要文本,添加标签高亮
|
||
const formatSummary = (text: string): string => {
|
||
const keywords: Record<string, { color: string, bg: string }> = {
|
||
'质量领跑': { color: 'text-emerald-800', bg: 'bg-emerald-100' },
|
||
'估值下行': { color: 'text-red-800', bg: 'bg-red-100' },
|
||
'历史低位': { color: 'text-emerald-800', bg: 'bg-emerald-100' },
|
||
'估值质量高': { color: 'text-blue-800', bg: 'bg-blue-100' },
|
||
'历史高位': { color: 'text-red-800', bg: 'bg-red-100' },
|
||
'历史极低位': { color: 'text-gray-800', bg: 'bg-gray-100' },
|
||
'核心资产': { color: 'text-blue-800', bg: 'bg-blue-100' },
|
||
'压力仍存': { color: 'text-orange-800', bg: 'bg-orange-100' },
|
||
'趋势上行': { color: 'text-emerald-800', bg: 'bg-emerald-100' },
|
||
'高位': { color: 'text-red-800', bg: 'bg-red-100' },
|
||
};
|
||
|
||
let formatted = text;
|
||
Object.entries(keywords).forEach(([keyword, styles]) => {
|
||
// 创建正则,允许关键词中间有可选空格
|
||
const keywordWithOptionalSpaces = keyword.split('').join('\\s*');
|
||
const regex = new RegExp(keywordWithOptionalSpaces, 'g');
|
||
formatted = formatted.replace(
|
||
regex,
|
||
(match) => `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${styles.bg} ${styles.color} mx-1">${match.replace(/\s+/g, '')}</span>`
|
||
);
|
||
});
|
||
|
||
return formatted;
|
||
};
|
||
|
||
export const DashboardPage: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const [modules, setModules] = useState(INITIAL_DATA);
|
||
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||
const [isSaveTemplateOpen, setIsSaveTemplateOpen] = useState(false);
|
||
const [templateName, setTemplateName] = useState('');
|
||
const [rightPanelVisible, setRightPanelVisible] = useState(false); // 默认隐藏
|
||
const [shouldRenderCharts, setShouldRenderCharts] = useState(false); // 控制图表渲染
|
||
const [isEditMode, setIsEditMode] = useState(false); // 编辑模式状态
|
||
const [showSortSuccessAlert, setShowSortSuccessAlert] = useState(false); // 排序成功提示
|
||
const [dimensionRatings, setDimensionRatings] = useState<Record<string, number>>({
|
||
valuation: 0,
|
||
capital: 0,
|
||
macro: 0,
|
||
assets: 0,
|
||
}); // 维度评分
|
||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
|
||
valuation: true, // 默认展开
|
||
capital: true,
|
||
macro: true,
|
||
assets: true,
|
||
});
|
||
|
||
// 拖拽排序相关状态
|
||
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
|
||
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
|
||
const dragItemRef = useRef<number | null>(null);
|
||
const dragOverItemRef = useRef<number | null>(null);
|
||
const dragGroupRef = useRef<string | null>(null);
|
||
|
||
const toggleGroup = (id: string) => {
|
||
setExpandedGroups(prev => ({ ...prev, [id]: !prev[id] }));
|
||
};
|
||
|
||
// 拖拽开始
|
||
const handleItemDragStart = (e: React.DragEvent, groupId: string, itemIndex: number) => {
|
||
console.log('拖拽开始', { isEditMode, groupId, itemIndex });
|
||
if (!isEditMode) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
dragItemRef.current = itemIndex;
|
||
dragGroupRef.current = groupId;
|
||
setDraggedItemId(`${groupId}-${itemIndex}`);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
};
|
||
|
||
// 拖拽进入
|
||
const handleItemDragEnter = (e: React.DragEvent, groupId: string, itemIndex: number) => {
|
||
if (!isEditMode) return;
|
||
console.log('拖拽进入', { groupId, itemIndex, dragGroupRef: dragGroupRef.current });
|
||
e.preventDefault();
|
||
if (dragGroupRef.current === groupId) {
|
||
dragOverItemRef.current = itemIndex;
|
||
}
|
||
};
|
||
|
||
// 拖拽结束
|
||
const handleItemDragEnd = (e: React.DragEvent, groupId: string) => {
|
||
console.log('拖拽结束', {
|
||
isEditMode,
|
||
groupId,
|
||
dragItemRef: dragItemRef.current,
|
||
dragOverItemRef: dragOverItemRef.current,
|
||
dragGroupRef: dragGroupRef.current
|
||
});
|
||
e.preventDefault();
|
||
|
||
// 先保存 ref 值,因为会在重置后才执行 setModules 回调
|
||
const dragFromIndex = dragItemRef.current;
|
||
const dragToIndex = dragOverItemRef.current;
|
||
const dragFromGroup = dragGroupRef.current;
|
||
|
||
if (
|
||
isEditMode &&
|
||
dragFromIndex !== null &&
|
||
dragToIndex !== null &&
|
||
dragFromGroup === groupId &&
|
||
dragFromIndex !== dragToIndex
|
||
) {
|
||
console.log('开始执行拖拽交换', { dragFromIndex, dragToIndex });
|
||
setModules(prevModules => {
|
||
console.log('当前modules:', prevModules);
|
||
const newModules = [...prevModules];
|
||
const groupIndex = newModules.findIndex(m => m.id === groupId);
|
||
console.log('找到groupIndex:', groupIndex);
|
||
|
||
if (groupIndex !== -1 && newModules[groupIndex].items) {
|
||
const items = [...newModules[groupIndex].items];
|
||
console.log('items长度:', items.length);
|
||
console.log('dragFromIndex:', dragFromIndex);
|
||
console.log('dragToIndex:', dragToIndex);
|
||
|
||
// 安全检查:确保索引有效
|
||
if (dragFromIndex >= 0 && dragFromIndex < items.length) {
|
||
const draggedItem = items[dragFromIndex];
|
||
console.log('拖拽的item:', draggedItem);
|
||
|
||
// 确保 draggedItem 存在
|
||
if (draggedItem) {
|
||
items.splice(dragFromIndex, 1);
|
||
|
||
// 计算正确的插入位置
|
||
const insertIndex = Math.min(dragToIndex, items.length);
|
||
console.log('插入位置:', insertIndex);
|
||
items.splice(insertIndex, 0, draggedItem);
|
||
|
||
newModules[groupIndex] = {
|
||
...newModules[groupIndex],
|
||
items
|
||
};
|
||
console.log('更新后的items:', items);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('返回新modules:', newModules);
|
||
return newModules;
|
||
});
|
||
|
||
// 显示成功提示
|
||
setShowSortSuccessAlert(true);
|
||
setTimeout(() => {
|
||
setShowSortSuccessAlert(false);
|
||
}, 2000);
|
||
}
|
||
|
||
// 始终重置拖拽状态
|
||
dragItemRef.current = null;
|
||
dragOverItemRef.current = null;
|
||
dragGroupRef.current = null;
|
||
setDraggedItemId(null);
|
||
};
|
||
|
||
// 拖拽悬停
|
||
const handleItemDragOver = (e: React.DragEvent) => {
|
||
if (!isEditMode) return;
|
||
e.preventDefault();
|
||
};
|
||
|
||
const handleScrollTo = (elementId: string, itemId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
// 先显示右侧面板
|
||
setRightPanelVisible(true);
|
||
|
||
// 左侧滚动到对应的指标项
|
||
requestAnimationFrame(() => {
|
||
const leftElement = document.getElementById(`left-${itemId}`);
|
||
if (leftElement) {
|
||
leftElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
});
|
||
|
||
// 延迟300ms后渲染图表,等待动画完成
|
||
setTimeout(() => {
|
||
setShouldRenderCharts(true);
|
||
// 再延迟一帧滚动到对应位置
|
||
requestAnimationFrame(() => {
|
||
const element = document.getElementById(elementId);
|
||
if (element) {
|
||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
});
|
||
}, 300);
|
||
};
|
||
|
||
const handleGroupClick = (groupId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
// 先显示右侧面板
|
||
setRightPanelVisible(true);
|
||
|
||
// 左侧滚动到对应的维度卡片
|
||
requestAnimationFrame(() => {
|
||
const leftElement = document.getElementById(`left-group-${groupId}`);
|
||
if (leftElement) {
|
||
leftElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
});
|
||
|
||
// 延迟300ms后渲染图表
|
||
setTimeout(() => {
|
||
setShouldRenderCharts(true);
|
||
const group = modules.find(m => m.id === groupId);
|
||
if (group && group.items.length > 0) {
|
||
requestAnimationFrame(() => {
|
||
const element = document.getElementById(`chart-${group.items[0].id}`);
|
||
if (element) {
|
||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
});
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
const handleApplyTemplate = () => {
|
||
setIsTemplateLibraryOpen(false);
|
||
};
|
||
|
||
// 保存模板
|
||
const handleSaveTemplate = () => {
|
||
if (templateName.trim()) {
|
||
// 这里可以添加实际的保存逻辑
|
||
console.log('保存模板:', templateName);
|
||
setIsSaveTemplateOpen(false);
|
||
setTemplateName('');
|
||
// 可以显示成功提示
|
||
}
|
||
};
|
||
|
||
// 关闭右侧面板时,重置图表渲染状态
|
||
const handleCloseRightPanel = () => {
|
||
setRightPanelVisible(false);
|
||
setShouldRenderCharts(false);
|
||
};
|
||
|
||
const getDimensionColor = (id: string) => {
|
||
const colorMap: Record<string, { left: string, bg: string, hover: string, icon: string }> = {
|
||
'valuation': { left: 'bg-blue-500', bg: 'bg-white', hover: 'hover:bg-gray-50', icon: 'bg-blue-50 text-blue-500' },
|
||
'capital': { left: 'bg-purple-500', bg: 'bg-white', hover: 'hover:bg-gray-50', icon: 'bg-purple-50 text-purple-500' },
|
||
'macro': { left: 'bg-teal-500', bg: 'bg-white', hover: 'hover:bg-gray-50', icon: 'bg-teal-50 text-teal-500' },
|
||
'assets': { left: 'bg-indigo-500', bg: 'bg-white', hover: 'hover:bg-gray-50', icon: 'bg-indigo-50 text-indigo-500' },
|
||
};
|
||
return colorMap[id] || colorMap['valuation'];
|
||
};
|
||
|
||
// 删除整个维度
|
||
const handleDeleteGroup = (groupId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (window.confirm('确定要删除整个维度吗?')) {
|
||
setModules(prevModules => prevModules.filter(m => m.id !== groupId));
|
||
}
|
||
};
|
||
|
||
// 删除单个指标
|
||
const handleDeleteItem = (groupId: string, itemId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (window.confirm('确定要删除这个指标吗?')) {
|
||
setModules(prevModules => {
|
||
return prevModules.map(group => {
|
||
if (group.id === groupId) {
|
||
return {
|
||
...group,
|
||
items: group.items.filter(item => item && item.id !== itemId)
|
||
};
|
||
}
|
||
return group;
|
||
});
|
||
});
|
||
}
|
||
};
|
||
|
||
// 设置维度评分
|
||
const handleRatingChange = (groupId: string, rating: number, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setDimensionRatings(prev => ({
|
||
...prev,
|
||
[groupId]: rating
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="flex min-h-screen bg-slate-50 overflow-hidden relative">
|
||
|
||
{/* 排序成功提示 */}
|
||
{showSortSuccessAlert && (
|
||
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fade-in">
|
||
<div className="bg-green-50 border border-green-200 rounded-lg px-6 py-3 shadow-lg flex items-center gap-2">
|
||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
<span className="text-sm text-green-700 font-medium">
|
||
排序已保存
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||
{/* Top Header */}
|
||
<header className="h-16 bg-white border-b border-slate-200 px-4 sm:px-6 flex items-center justify-between shrink-0 z-20">
|
||
<div className="flex items-center gap-2 sm:gap-3 text-sm min-w-0">
|
||
<span onClick={() => navigate('/')} className="cursor-pointer text-slate-500 hover:text-blue-600 transition-colors font-medium whitespace-nowrap">首页</span>
|
||
<ChevronRight size={14} className="text-slate-300 flex-shrink-0" />
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="font-bold text-base sm:text-lg text-slate-900 tracking-tight truncate">贵州茅台</span>
|
||
<span className="bg-gray-100 text-slate-600 px-1.5 sm:px-2 py-0.5 rounded text-xs font-mono font-semibold border border-gray-200 whitespace-nowrap">600519.SH</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 编辑模式提示 - 居中显示 */}
|
||
{isEditMode && (
|
||
<div className="flex-1 flex justify-center mx-4">
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 flex items-center gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<GripVertical size={16} className="text-blue-600" />
|
||
<span className="text-sm text-blue-700 font-medium whitespace-nowrap">
|
||
编辑模式已开启,可排序、删除等操作
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsEditMode(false)}
|
||
className="text-xs px-3 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium whitespace-nowrap"
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 sm:gap-5">
|
||
<button
|
||
onClick={() => setIsTemplateLibraryOpen(true)}
|
||
className="flex items-center gap-1 sm:gap-1.5 text-slate-600 hover:text-blue-600 transition-colors text-sm font-medium group"
|
||
>
|
||
<LayoutTemplate size={18} className="group-hover:scale-110 transition-transform flex-shrink-0" />
|
||
<span className="hidden sm:inline">模板库</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setIsSaveTemplateOpen(true)}
|
||
className="flex items-center gap-1 sm:gap-1.5 text-slate-600 hover:text-blue-600 transition-colors text-sm font-medium group"
|
||
>
|
||
<Save size={18} className="group-hover:scale-110 transition-transform flex-shrink-0" />
|
||
<span className="hidden sm:inline">另存为</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main Content - Split Layout */}
|
||
<div className="flex-1 flex overflow-hidden">
|
||
|
||
{/* Left Panel: Rating Snapshot Table */}
|
||
<div className={`bg-white border-r border-slate-200 flex flex-col z-10 shadow-sm transition-all duration-300 ${
|
||
rightPanelVisible
|
||
? 'w-full lg:w-[380px] xl:w-[480px] 2xl:w-[720px]'
|
||
: 'w-full'
|
||
}`}>
|
||
<div className={`flex flex-col flex-1 min-h-0 ${!rightPanelVisible ? 'max-w-[1440px] mx-auto w-full' : ''}`}>
|
||
<div className="p-4 border-b border-slate-100 bg-white shrink-0">
|
||
<div className={`flex items-center ${rightPanelVisible ? 'justify-between' : 'justify-center'}`}>
|
||
<h2 className="text-base sm:text-lg font-bold text-slate-900 flex items-center gap-2">
|
||
<span style={{ fontSize: '26px' }}>资产评级表</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const newMode = !isEditMode;
|
||
console.log('切换编辑模式:', newMode);
|
||
setIsEditMode(newMode);
|
||
}}
|
||
className={`p-1.5 rounded-lg transition-colors group ${
|
||
isEditMode
|
||
? 'text-blue-600 bg-blue-50'
|
||
: 'text-slate-600 hover:text-blue-600 hover:bg-blue-50'
|
||
}`}
|
||
title="设置维度和视角"
|
||
>
|
||
<Settings size={16} className="group-hover:rotate-90 transition-transform duration-300" />
|
||
</button>
|
||
</h2>
|
||
{rightPanelVisible && (
|
||
<button
|
||
onClick={handleCloseRightPanel}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors group"
|
||
title="全屏显示快照列表"
|
||
>
|
||
<X size={16} className="group-hover:scale-110 transition-transform" />
|
||
<span className="hidden sm:inline">关闭详情</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{!rightPanelVisible && (
|
||
<div className="text-sm text-slate-400 mt-3 text-center">综合估值、资金、宏观、基本面四大维度,多视角量化评估资产投资价值</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||
{/* 维度布局:右侧展开时在大屏幕(>1440px)显示2列,否则1列;未展开时两列 */}
|
||
<div className={`grid gap-4 ${
|
||
rightPanelVisible
|
||
? 'grid-cols-1 2xl:grid-cols-2'
|
||
: 'grid-cols-1 lg:grid-cols-2'
|
||
}`}>
|
||
{modules.map((group) => {
|
||
const colors = getDimensionColor(group.id);
|
||
|
||
return (
|
||
<div
|
||
key={group.id}
|
||
className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden hover:shadow-md transition-all group/card"
|
||
>
|
||
{/* 维度标题 */}
|
||
<div
|
||
onClick={(e) => !isEditMode && handleGroupClick(group.id, e)}
|
||
className={`relative px-4 py-3 border-b border-slate-200 ${!isEditMode ? 'cursor-pointer' : ''} ${colors.hover} transition-colors`}
|
||
>
|
||
<div className={`absolute left-0 top-0 bottom-0 w-1 ${colors.left}`}></div>
|
||
<div className="flex flex-col gap-2 pl-2">
|
||
{/* 第一行:图标、标题、评分、删除按钮 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div className={`p-1.5 ${colors.icon} rounded-lg`}>
|
||
{group.icon}
|
||
</div>
|
||
<h2 className="text-sm font-bold text-slate-900">
|
||
{group.dimension}
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* 编辑模式下显示评分星星 */}
|
||
{isEditMode && (
|
||
<div className="flex items-center gap-1">
|
||
{[1, 2, 3, 4, 5].map((star) => (
|
||
<button
|
||
key={star}
|
||
onClick={(e) => handleRatingChange(group.id, star, e)}
|
||
className="transition-colors"
|
||
title={`${star}星`}
|
||
>
|
||
<Star
|
||
size={16}
|
||
className={`${
|
||
star <= dimensionRatings[group.id]
|
||
? 'text-yellow-500 fill-yellow-500'
|
||
: 'text-gray-300'
|
||
}`}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 编辑模式下显示删除按钮 */}
|
||
{isEditMode && (
|
||
<button
|
||
onClick={(e) => handleDeleteGroup(group.id, e)}
|
||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||
title="删除维度"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 第二行:维度描述 */}
|
||
{group.description && (
|
||
<p className="text-xs text-slate-400 pl-9 leading-relaxed">
|
||
{group.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 指标列表 */}
|
||
<div className="divide-y divide-slate-100" id={`left-group-${group.id}`}>
|
||
{group.items?.map((item, itemIndex) => {
|
||
// 跳过 undefined 的 item
|
||
if (!item) return null;
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
id={`left-${item.id}`}
|
||
draggable={isEditMode}
|
||
onDragStart={(e) => handleItemDragStart(e, group.id, itemIndex)}
|
||
onDragEnter={(e) => handleItemDragEnter(e, group.id, itemIndex)}
|
||
onDragEnd={(e) => handleItemDragEnd(e, group.id)}
|
||
onDragOver={handleItemDragOver}
|
||
onClick={(e) => {
|
||
if (!isEditMode) {
|
||
handleScrollTo(`chart-${item.id}`, item.id, e);
|
||
}
|
||
}}
|
||
className={`p-4 hover:bg-slate-50 transition-all ${
|
||
isEditMode ? 'cursor-move' : 'cursor-pointer'
|
||
} ${draggedItemId === `${group.id}-${itemIndex}` ? 'opacity-50' : ''} group`}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<GripVertical
|
||
size={14}
|
||
className={`text-slate-300 mt-0.5 flex-shrink-0 transition-opacity ${
|
||
isEditMode ? 'opacity-100' : 'opacity-0'
|
||
}`}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
{/* 指标标题 */}
|
||
<h3 className="text-sm font-bold text-slate-900 mb-2 group-hover:text-blue-600 transition-colors">
|
||
{item.label}
|
||
</h3>
|
||
|
||
{/* 摘要 - 编辑模式下隐藏 */}
|
||
{!isEditMode && (
|
||
<p className="text-xs text-slate-600 leading-relaxed">
|
||
{item.summary}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 编辑模式下显示删除按钮 */}
|
||
{isEditMode && (
|
||
<button
|
||
onClick={(e) => handleDeleteItem(group.id, item.id, e)}
|
||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors flex-shrink-0"
|
||
title="删除指标"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 编辑模式下显示"添加视角"按钮 */}
|
||
{isEditMode && (
|
||
<div className="p-4 border-t border-slate-100 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
// TODO: 打开添加视角的对话框
|
||
alert('添加视角功能');
|
||
}}
|
||
className="w-full flex items-center justify-center gap-2 py-2.5 text-sm text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors border border-dashed border-blue-300 hover:border-blue-400"
|
||
>
|
||
<Plus size={16} />
|
||
<span className="font-medium">添加视角</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 编辑模式下显示"添加维度"按钮 */}
|
||
{isEditMode && (
|
||
<div className="bg-white rounded-xl shadow-sm border-2 border-dashed border-slate-300 overflow-hidden hover:shadow-md hover:border-blue-400 transition-all">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
// TODO: 打开添加维度的对话框
|
||
alert('添加维度功能');
|
||
}}
|
||
className="w-full h-full min-h-[200px] flex flex-col items-center justify-center gap-3 text-slate-500 hover:text-blue-600 transition-colors p-8"
|
||
>
|
||
<div className="w-12 h-12 rounded-full bg-slate-100 hover:bg-blue-50 flex items-center justify-center transition-colors">
|
||
<Plus size={24} />
|
||
</div>
|
||
<span className="text-sm font-medium">添加维度</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Panel: Charts */}
|
||
{rightPanelVisible && (
|
||
<div className="hidden lg:flex flex-1 relative flex-col min-w-0 bg-slate-50">
|
||
<div className="flex-1 overflow-y-auto p-6 scroll-smooth">
|
||
<div className="max-w-4xl mx-auto space-y-12">
|
||
{modules.map((group) => (
|
||
<div key={group.id} id={`group-${group.id}`} className="space-y-6 scroll-mt-6">
|
||
{/* Section Header */}
|
||
<div className="flex items-center gap-3 pl-2 border-l-4 border-slate-900">
|
||
<h2 className="text-xl font-bold text-slate-900">{group.dimension}</h2>
|
||
</div>
|
||
|
||
{/* Charts List */}
|
||
<div className="space-y-6">
|
||
{group.items.map((item) => (
|
||
<div
|
||
id={`chart-${item.id}`}
|
||
key={item.id}
|
||
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 hover:shadow-md transition-all scroll-mt-24"
|
||
>
|
||
<div className="mb-4">
|
||
<h3 className="text-base font-bold text-slate-900">{item.label}</h3>
|
||
<span className="text-xs text-slate-400">{group.dimension}</span>
|
||
</div>
|
||
|
||
{/* Chart */}
|
||
<div className="h-80 w-full mb-4">
|
||
{shouldRenderCharts ? (
|
||
<DetailChart data={item.chartData} color={item.color} label={item.label} />
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-slate-50 rounded-lg">
|
||
<div className="text-slate-400 text-sm">加载中...</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Summary */}
|
||
<div className="bg-slate-50 rounded-xl p-4 border border-slate-100 flex items-start justify-between gap-3">
|
||
<p className="text-sm text-slate-700 font-medium italic flex-1 text-center">
|
||
{item.summary}
|
||
</p>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
navigate('/detail');
|
||
}}
|
||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors flex-shrink-0"
|
||
title="查看详情"
|
||
>
|
||
<span>Ask</span>
|
||
<ArrowUpRight size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* Template Library Modal */}
|
||
{isTemplateLibraryOpen && (
|
||
<TemplateLibraryModal onClose={() => setIsTemplateLibraryOpen(false)} onApply={handleApplyTemplate} />
|
||
)}
|
||
|
||
{/* Save Template Modal */}
|
||
{isSaveTemplateOpen && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
||
{/* Header */}
|
||
<div className="p-6 border-b border-slate-200">
|
||
<h2 className="text-xl font-bold text-slate-900">保存为模板</h2>
|
||
<p className="text-sm text-slate-500 mt-1">输入模板名称以保存当前配置</p>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-6">
|
||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||
模板名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={templateName}
|
||
onChange={(e) => setTemplateName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && templateName.trim()) {
|
||
handleSaveTemplate();
|
||
}
|
||
}}
|
||
placeholder="例如:茅台估值分析模板"
|
||
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-6 border-t border-slate-200 flex items-center justify-end gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setIsSaveTemplateOpen(false);
|
||
setTemplateName('');
|
||
}}
|
||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleSaveTemplate}
|
||
disabled={!templateName.trim()}
|
||
className={`px-6 py-2 text-sm font-bold text-white rounded-lg transition-colors ${
|
||
templateName.trim()
|
||
? 'bg-blue-600 hover:bg-blue-700'
|
||
: 'bg-slate-300 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Chart Component
|
||
const DetailChart: React.FC<{data: any[], color: string, label: string}> = ({ data, color, label }) => (
|
||
<StockChart data={data} color={color} legend={`贵州茅台_${label.split(' ')[0]}`} />
|
||
);
|
||
|
||
// Template Library Modal
|
||
const TemplateLibraryModal: React.FC<{onClose: () => void, onApply: () => void}> = ({ onClose, onApply }) => {
|
||
const [activeTab, setActiveTab] = useState<'general' | 'my'>('general');
|
||
|
||
const GENERAL_TEMPLATES = [
|
||
{ id: 't1', title: '个股通用深度模板', desc: '包含估值、资金、宏观、财务四大核心维度', icon: <FileText size={20} className="text-blue-600" />, tags: ['全市场', '通用'] },
|
||
{ id: 't2', title: '快速诊断模板', desc: '精简版模板,聚焦于PE/PB band与短期资金流向', icon: <TrendingUp size={20} className="text-amber-500" />, tags: ['短线', '效率'] }
|
||
];
|
||
|
||
const MY_TEMPLATES = [
|
||
{ id: 'm1', title: '宁德时代评级表', desc: '包含新能源行业特色指标、产能利用率、技术路线分析', icon: <FileText size={20} className="text-emerald-600" />, tags: ['新能源', '自定义'], date: '2024-03-15' },
|
||
{ id: 'm2', title: '茅台估值分析模板', desc: '聚焦消费品估值体系、品牌溢价、渠道分析', icon: <FileText size={20} className="text-purple-600" />, tags: ['消费', '自定义'], date: '2024-03-10' }
|
||
];
|
||
|
||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||
const currentTemplates = activeTab === 'general' ? GENERAL_TEMPLATES : MY_TEMPLATES;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
|
||
<div className="bg-white w-full max-w-2xl max-h-[90vh] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
||
<div className="h-14 sm:h-16 border-b border-slate-100 flex items-center justify-between px-4 sm:px-6">
|
||
<h2 className="text-base sm:text-lg font-bold text-slate-900">模板库</h2>
|
||
<button onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full">✕</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="border-b border-slate-200 px-4 sm:px-6">
|
||
<div className="flex gap-6">
|
||
<button
|
||
onClick={() => setActiveTab('general')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeTab === 'general'
|
||
? 'border-blue-600 text-blue-600'
|
||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
通用模板
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('my')}
|
||
className={`py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
activeTab === 'my'
|
||
? 'border-blue-600 text-blue-600'
|
||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
我的模板
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||
<div className="space-y-3">
|
||
{currentTemplates.map((template) => (
|
||
<div
|
||
key={template.id}
|
||
onClick={() => setSelectedTemplate(template.id)}
|
||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||
selectedTemplate === template.id
|
||
? 'bg-blue-50 border-blue-500 ring-1 ring-blue-500'
|
||
: 'bg-white border-slate-200 hover:border-blue-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div className="p-2 rounded-lg bg-slate-50">{template.icon}</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<h4 className="text-sm font-bold">{template.title}</h4>
|
||
{activeTab === 'my' && 'date' in template && (
|
||
<span className="text-xs text-slate-400">{(template as any).date}</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-slate-500 mb-2">{template.desc}</p>
|
||
<div className="flex gap-2">
|
||
{template.tags.map(tag => (
|
||
<span key={tag} className="px-2 py-0.5 text-xs rounded bg-slate-100 text-slate-500">{tag}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-16 border-t border-slate-200 px-4 sm:px-6 flex items-center justify-end gap-3">
|
||
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-lg">取消</button>
|
||
<button
|
||
disabled={!selectedTemplate}
|
||
onClick={onApply}
|
||
className={`px-6 py-2 text-sm font-bold text-white rounded-lg ${
|
||
selectedTemplate ? 'bg-blue-600 hover:bg-blue-700' : 'bg-slate-300 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
应用模板
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|