Initial project setup with React, Vite, and Tailwind CSS. Added core application files including routing, components, and mock data. Configured package.json, .gitignore, and build settings. Included README for deployment instructions.

This commit is contained in:
褚宏光 2026-01-23 18:03:38 +08:00
parent 9cc0c04181
commit 74598abcfb
22 changed files with 5191 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

21
App.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from 'react';
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { SearchPage } from './pages/SearchPage';
import { DashboardPage } from './pages/DashboardPage';
import { DetailPage } from './pages/DetailPage';
const App: React.FC = () => {
return (
<Router>
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/detail" element={<DetailPage />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
);
};
export default App;

View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1ukYcPlCYB97O2tjajF3GI8pxlmamIjpO
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

117
README_DEPLOY.md Normal file
View File

@ -0,0 +1,117 @@
# 部署到自己服务器指南
## ✅ 已完成的优化
### 1. 移除 Tailwind CSS CDN
- ✅ 已安装 `tailwindcss@^3``postcss``autoprefixer`
- ✅ 已创建 `tailwind.config.js``postcss.config.js`
- ✅ 已创建 `src/index.css` 并在 `index.tsx` 中导入
- ✅ 样式会被打包到 `dist/assets/index-*.css`
### 2. 移除 React 相关的 ESM.sh CDN
- ✅ `package.json` 中已有所有依赖
- ✅ 移除了 HTML 中的 `<script type="importmap">`
- ✅ Vite 会自动打包所有 React 相关依赖到 `dist/assets/index-*.js`
### 3. 字体处理
- ⚠️ 目前仍使用 Google Fonts CDN
- 选项1保持 CDN推荐字体文件大
- 选项2本地化字体见下文
## 📦 构建和部署步骤
### 1. 安装依赖
```bash
npm install
```
### 2. 构建生产版本
```bash
npm run build
```
### 3. 检查 dist 目录
构建后会生成:
```
dist/
├── index.html
└── assets/
├── index-[hash].js # 所有JS打包后的文件
└── index-[hash].css # 所有CSS打包后的文件
```
### 4. 部署到服务器
将整个 `dist` 目录上传到服务器,例如:
```bash
# Nginx 配置示例
server {
listen 80;
server_name your-domain.com;
root /path/to/dist;
location / {
try_files $uri $uri/ /index.html;
}
}
```
## 🎨 可选:本地化 Google Fonts
如果需要完全离线,下载 Inter 字体:
1. 安装字体文件到 `public/fonts/`
2. 修改 `src/index.css`
```css
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Regular.woff2') format('woff2');
font-weight: 400;
}
/* 其他字重... */
@tailwind base;
@tailwind components;
@tailwind utilities;
```
## ⚙️ 环境变量
如果使用环境变量(如 API Key在服务器上设置
```bash
# .env.production
GEMINI_API_KEY=your_api_key
```
构建时会自动注入到代码中(见 `vite.config.ts`)。
## 🚀 性能优化建议
当前打包文件较大 (621KB),可以考虑:
1. **代码分割**:使用动态导入
```typescript
const DetailPage = lazy(() => import('./pages/DetailPage'));
```
2. **手动分包** (vite.config.ts)
```typescript
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'charts': ['recharts'],
}
}
}
}
```
## 📝 注意事项
1. ✅ 所有依赖已本地化(除字体)
2. ✅ 无需依赖外部 CDN除 Google Fonts
3. ✅ 支持单页面应用路由
4. ⚠️ 确保服务器配置了正确的 MIME 类型
5. ⚠️ 如果使用 HTTPSGoogle Fonts CDN 可正常工作

12
components/Logo.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
export const Logo: React.FC = () => {
return (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-slate-900 rounded-lg flex items-center justify-center text-white font-bold text-xs">
Ai<sup className="text-[0.6rem] ml-0.5">+</sup>
</div>
<span className="text-xl font-bold tracking-tight text-slate-900">QuantBuddy (demo)</span>
</div>
);
};

125
components/Sidebar.tsx Normal file
View File

@ -0,0 +1,125 @@
import React, { useState, useRef } from 'react';
import { LayoutGrid, Settings2, Trash2, GripVertical, X, Check } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Logo } from './Logo';
interface SidebarItem {
id: string;
label: string;
icon: React.ReactNode;
}
interface SidebarProps {
items: SidebarItem[];
onReorder: (newItems: SidebarItem[]) => void;
onDelete: (id: string) => void;
onItemClick: (id: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ items, onReorder, onDelete, onItemClick }) => {
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, position: number) => {
dragItem.current = position;
e.dataTransfer.effectAllowed = "move";
// Add a ghost class or style if needed
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>, position: number) => {
dragOverItem.current = position;
};
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (dragItem.current !== null && dragOverItem.current !== null) {
const copyListItems = [...items];
const dragItemContent = copyListItems[dragItem.current];
copyListItems.splice(dragItem.current, 1);
copyListItems.splice(dragOverItem.current, 0, dragItemContent);
onReorder(copyListItems);
}
dragItem.current = null;
dragOverItem.current = null;
};
return (
<aside className="w-64 bg-white border-r border-slate-200 flex-col hidden md:flex h-screen sticky top-0 select-none">
<div
className="h-16 flex items-center px-6 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => navigate('/')}
>
<Logo />
</div>
<div className="flex-1 overflow-y-auto py-6 px-3 space-y-6">
<div>
<div className="flex items-center justify-between px-3 mb-2 text-slate-900 font-semibold group">
<div className="flex items-center gap-3">
<LayoutGrid size={20} />
<span></span>
</div>
<button
onClick={() => setIsEditing(!isEditing)}
className={`p-1.5 rounded-md transition-all ${isEditing ? 'bg-blue-100 text-blue-600' : 'text-slate-400 hover:text-slate-700 hover:bg-slate-100'}`}
title={isEditing ? "完成编辑" : "管理维度"}
>
{isEditing ? <Check size={16} /> : <Settings2 size={16} />}
</button>
</div>
<div className="text-xs font-medium text-slate-500 px-3 mb-3 uppercase tracking-wider">
(600519.SH)
</div>
<nav className="space-y-1">
{items.map((item, index) => (
<div
key={item.id}
draggable={isEditing}
onDragStart={(e) => handleDragStart(e, index)}
onDragEnter={(e) => handleDragEnter(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
onClick={() => !isEditing && onItemClick(item.id)}
className={`
flex items-center gap-3 px-3 py-2 rounded-lg font-medium text-sm transition-all relative group
${isEditing ? 'cursor-move hover:bg-slate-50 border border-transparent hover:border-slate-200' : 'cursor-pointer text-slate-600 hover:bg-slate-50'}
`}
>
{isEditing && (
<div className="text-slate-400">
<GripVertical size={16} />
</div>
)}
<div className={`${isEditing ? 'text-slate-500' : 'text-slate-600'}`}>
{item.icon}
</div>
<span className="flex-1">{item.label}</span>
{isEditing && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
>
<Trash2 size={16} />
</button>
)}
</div>
))}
</nav>
</div>
</div>
{isEditing && (
<div className="p-4 bg-blue-50/50 border-t border-blue-100 text-center text-xs text-blue-600 font-medium">
</div>
)}
</aside>
);
};

127
components/StockChart.tsx Normal file
View File

@ -0,0 +1,127 @@
import React, { useState, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Brush } from 'recharts';
interface StockChartProps {
data: Array<{ date: string; value: number }>;
color?: string;
showBrush?: boolean;
height?: string;
showTimeRange?: boolean; // 是否显示时间范围选择
legend?: string; // 图例文本
}
export const StockChart: React.FC<StockChartProps> = ({
data,
color = '#882323',
showBrush = true,
height = '100%',
showTimeRange = true,
legend
}) => {
const [selectedRange, setSelectedRange] = useState<string>('ALL');
// 根据选择的时间范围过滤数据
const filteredData = useMemo(() => {
if (selectedRange === 'ALL') return data;
const rangeMap: Record<string, number> = {
'1Y': 12,
'3Y': 36,
'5Y': 60
};
const months = rangeMap[selectedRange];
if (!months) return data;
return data.slice(-months);
}, [data, selectedRange]);
return (
<div className="relative w-full h-full flex flex-col">
{/* 顶部栏:图例和时间范围选择 */}
<div className="flex items-center justify-between mb-2">
{/* 图例 - 左上角 */}
{legend && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: color }}></span>
<span>{legend}</span>
</div>
)}
{/* 时间范围选择按钮 - 右上角 */}
{showTimeRange && (
<div className="flex items-center gap-1 ml-auto">
{['1Y', '3Y', '5Y', 'ALL'].map(range => (
<button
key={range}
onClick={() => setSelectedRange(range)}
className={`px-2 py-1 text-xs font-medium rounded transition-all ${
selectedRange === range
? 'bg-slate-900 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
}`}
>
{range}
</button>
))}
</div>
)}
</div>
{/* 图表容器 */}
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={filteredData} margin={{ top: 5, right: 0, left: -20, bottom: showBrush ? 35 : 10 }}>
<defs>
<linearGradient id="brushGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.15} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fontSize: 10, fill: '#cbd5e1'}}
dy={10}
interval="preserveStartEnd"
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fontSize: 10, fill: '#cbd5e1'}}
domain={['dataMin - 1', 'dataMax + 1']}
/>
<Tooltip
contentStyle={{
borderRadius: '12px',
border: 'none',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
padding: '8px 12px'
}}
itemStyle={{ color: color, fontSize: '12px', fontWeight: 600 }}
labelStyle={{ color: '#64748b', fontSize: '10px', marginBottom: '4px' }}
/>
{showBrush && (
<Brush
dataKey="date"
height={20}
stroke="#c0c8e5"
fill="url(#brushGradient)"
travellerWidth={16}
/>
)}
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill="none"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
};

112
data/mockData.ts Normal file
View File

@ -0,0 +1,112 @@
// 共享的模拟数据生成器
export const generateChartData = (base: number, variance: number) => {
const data = [];
let currentValue = base;
for (let i = 0; i < 50; i++) {
const change = (Math.random() - 0.5) * variance;
currentValue += change;
data.push({
date: `202${Math.floor(i/12)}-${(i%12)+1}`,
value: Math.max(0, currentValue)
});
}
return data;
};
// 共享的数据配置
export const STOCK_DATA_CONFIG = {
valuation: {
id: 'valuation',
dimension: '估值逻辑',
items: [
{
id: 'pb',
label: 'PB 质量与对比',
summary: '茅台PB处低位,质量领跑但估值下行。',
chartData: generateChartData(18, 2),
color: '#882323',
},
{
id: 'pe',
label: 'PE 质量与对比',
summary: 'PE处历史低位,下行趋势凸显估值优势。',
chartData: generateChartData(50, 5),
color: '#882323',
},
{
id: 'ps',
label: 'PS 质量与对比',
summary: '茅台PS处历史低位,估值质量高。',
chartData: generateChartData(12, 1.5),
color: '#882323',
},
{
id: 'div',
label: '股息回报',
summary: '股息率处历史高位,上行趋势增强回报吸引力',
chartData: generateChartData(3, 0.5),
color: '#882323',
},
]
},
macro: {
id: 'macro',
dimension: '宏观胜率背景',
items: [
{
id: 'erp',
label: 'ERP 风险溢价',
summary: 'ERP 位于历史高位,权益资产性价比凸显。',
chartData: generateChartData(5.5, 0.8),
color: '#882323',
},
{
id: 'rate',
label: '无风险利率',
summary: '利率中枢下行,对核心资产估值形成支撑。',
chartData: generateChartData(2.5, 0.1),
color: '#882323',
}
]
},
assets: {
id: 'assets',
dimension: '相关资产走势',
items: [
{
id: 'peer',
label: '同行业主要公司',
summary: '贵州茅台处于估值中枢附近,行业龙头压力仍存。',
chartData: generateChartData(1.6, 0.2),
color: '#882323',
},
{
id: 'upstream',
label: '上下游主要公司',
summary: '茅台相对高位震荡,华致酒行趋势上行。',
chartData: generateChartData(1.5, 0.3),
color: '#882323',
},
{
id: 'style',
label: '同类型风格公司',
summary: '茅台与中免均处高位,同涨趋势延续。',
chartData: generateChartData(2.1, 0.4),
color: '#882323',
}
]
},
capital: {
id: 'capital',
dimension: '资金流向',
items: [
{
id: 'flow',
label: '市场成交活跃度',
summary: '成交活跃度处历史极低位,持续萎缩待拐点。',
chartData: generateChartData(100, 20),
color: '#882323',
}
]
}
};

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QuantBuddy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Normal file
View File

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './src/index.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "QuantBuddy",
"description": "AI-powered financial analysis dashboard featuring search, asset overview, and deep-dive valuation metrics.",
"requestFramePermissions": []
}

3225
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "quantbuddy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.12.0",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

861
pages/DashboardPage.tsx Normal file
View File

@ -0,0 +1,861 @@
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>
);
};

232
pages/DetailPage.tsx Normal file
View File

@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronRight, BarChart3, Plus, AtSign, Send, PieChart, Globe, TrendingUp } from 'lucide-react';
import { StockChart } from '../components/StockChart';
import { STOCK_DATA_CONFIG } from '../data/mockData';
// 已移至 data/mockData.ts 作为共享数据
// 研判结论函数
const getJudgmentConclusion = (dimension: string, tab: string): string => {
const conclusions: Record<string, Record<string, string>> = {
valuation: {
'PB 质量与对比': '茅台PB处于历史低位质量溢价收敛估值安全边际显著',
'PE 质量与对比': '茅台PE估值回落至合理区间盈利质量稳健支撑估值底部',
'PS 质量与对比': '市销率处于历史中枢偏下,收入增长预期稳定',
'股息回报': '股息率提升至历史高位,分红回报吸引力凸显'
},
macro: {
'ERP 风险溢价': 'ERP位于历史高位权益资产性价比凸显',
'无风险利率': '无风险利率处于下行通道,利好权益资产估值修复',
'信用利差': '信用利差收窄,市场风险偏好改善,流动性环境友好'
},
assets: {
'同行业主要公司': '茅台相对同行业走势强劲,行业龙头地位稳固',
'上下游主要公司': '产业链整体景气度提升,上下游传导效应积极',
'同类型风格公司': '消费龙头风格轮动回归,高端品牌溢价重估'
},
capital: {
'市场成交活跃度': '成交量温和放大,市场情绪逐步回暖',
'主力资金流向': '主力资金持续净流入,机构配置意愿增强',
'北向资金持仓': '北向资金持续增持,外资看好长期投资价值'
}
};
return conclusions[dimension]?.[tab] || '当前指标处于合理区间,关注后续走势变化';
};
// 图表解释函数
const getChartExplanation = (dimension: string, tab: string): string => {
const explanations: Record<string, Record<string, string>> = {
valuation: {
'PB 质量与对比': '该图展示贵州茅台市净率(PB)的历史走势。PB值越低说明相对净资产的估值越便宜。曲线下降表示估值压缩上升则表示估值扩张。',
'PE 质量与对比': '该图反映市盈率(PE)变化趋势。PE值越低说明股价相对盈利能力越便宜。可观察估值是否处于历史低位判断投资价值。',
'PS 质量与对比': '该图显示市销率(PS)的历史波动。PS衡量股价相对营收的估值水平低位通常意味着被低估的机会。',
'股息回报': '该图展示股息率的历史变化。股息率越高,持有股票的分红回报越好,是价值投资者关注的重要指标。'
},
macro: {
'ERP 风险溢价': '该图展示股权风险溢价(ERP)走势。ERP处于高位时权益资产相对债券更具吸引力是配置股票的良好时机。',
'无风险利率': '该图反映无风险利率(如国债收益率)的变化。利率下行通常利好股票估值,尤其是核心资产的长期价值。',
'信用利差': '该图显示信用利差的历史波动。利差扩大表示市场风险偏好下降,利差收窄则表示风险偏好回升。'
},
assets: {
'同行业主要公司': '该图对比茅台与同行业龙头的走势。可观察茅台相对行业的强弱,判断其竞争地位变化。',
'上下游主要公司': '该图展示茅台与产业链相关公司的走势对比。可从中洞察产业链景气度及传导效应。',
'同类型风格公司': '该图对比具有相似特征(如消费、高端品牌)的公司走势,帮助识别风格轮动和板块机会。'
},
capital: {
'市场成交活跃度': '该图反映市场成交量的变化。成交量萎缩通常意味着观望情绪浓厚,放量则表示资金入场或离场信号。',
'主力资金流向': '该图追踪主力资金的净流入流出。持续流入表示机构看好,流出则可能暗示减仓或调仓。',
'北向资金持仓': '该图显示外资通过陆股通的持仓变化。北向资金增持通常被视为积极信号,减持则需关注原因。'
}
};
return explanations[dimension]?.[tab] || '该图展示了相关指标的历史走势和变化趋势,可用于分析当前所处位置及未来可能的方向。';
};
const DIMENSIONS_CONFIG = {
valuation: {
label: '估值逻辑',
icon: <BarChart3 size={24} />,
tabs: STOCK_DATA_CONFIG.valuation.items.map(item => item.label),
data: STOCK_DATA_CONFIG.valuation.items,
color: '#882323'
},
capital: {
label: '资金流向',
icon: <PieChart size={24} />,
tabs: STOCK_DATA_CONFIG.capital.items.map(item => item.label),
data: STOCK_DATA_CONFIG.capital.items,
color: '#882323'
},
macro: {
label: '宏观胜率背景',
icon: <Globe size={24} />,
tabs: STOCK_DATA_CONFIG.macro.items.map(item => item.label),
data: STOCK_DATA_CONFIG.macro.items,
color: '#882323'
},
assets: {
label: '相关资产走势',
icon: <TrendingUp size={24} />,
tabs: STOCK_DATA_CONFIG.assets.items.map(item => item.label),
data: STOCK_DATA_CONFIG.assets.items,
color: '#882323'
}
};
export const DetailPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Get dimension from URL or default to 'valuation'
const dimensionKey = searchParams.get('tab') || 'valuation';
const isValidDimension = Object.keys(DIMENSIONS_CONFIG).includes(dimensionKey);
const activeDimensionKey = isValidDimension ? dimensionKey : 'valuation';
const currentConfig = DIMENSIONS_CONFIG[activeDimensionKey as keyof typeof DIMENSIONS_CONFIG];
const [activeSubTab, setActiveSubTab] = useState(currentConfig.tabs[0]);
// 获取当前选中tab对应的数据
const currentTabData = currentConfig.data.find(item => item.label === activeSubTab)?.chartData || currentConfig.data[0]?.chartData || [];
// When dimension changes (URL changes), reset sub-tab to the first one of that dimension
useEffect(() => {
setActiveSubTab(currentConfig.tabs[0]);
}, [activeDimensionKey]);
return (
<div className="flex min-h-screen bg-slate-50 text-slate-900 overflow-hidden">
<div className="flex-1 flex flex-col h-screen min-w-0">
{/* Header */}
<header className="h-14 bg-white border-b border-slate-200 px-6 flex items-center justify-between shrink-0">
<div className="flex items-center gap-4 text-sm text-slate-500">
<span onClick={() => navigate('/dashboard')} className="cursor-pointer hover:text-blue-600 transition-colors"></span>
<ChevronRight size={14} />
<span className="text-slate-900 font-medium"></span>
<span className="bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded text-xs font-mono">600519.SH</span>
</div>
<div className="flex items-center gap-6">
</div>
</header>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[1000px] mx-auto">
<div className="flex items-center gap-3 mb-4">
<div className="text-slate-900">
{currentConfig.icon}
</div>
<h1 className="text-xl font-bold">{currentConfig.label}</h1>
</div>
{/* Tabs */}
<div className="flex items-center gap-8 border-b border-slate-200 mb-4 overflow-x-auto no-scrollbar">
{currentConfig.tabs.map((tab) => {
const isActive = tab === activeSubTab;
return (
<button
key={tab}
onClick={() => setActiveSubTab(tab)}
className={`pb-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
{tab}
</button>
)
})}
<button className="pb-3 text-slate-400 hover:text-slate-600"><Plus size={18} /></button>
</div>
{/* Chart Card */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 mb-4">
<div className="flex flex-col lg:flex-row gap-8">
{/* Chart Area */}
<div className="flex-1 min-h-[300px]">
<div className="h-[320px] w-full">
<StockChart
data={currentTabData}
color="#882323"
height="100%"
legend={`贵州茅台_${activeSubTab.split(' ')[0]}`}
/>
</div>
</div>
{/* Right Panel: Conclusion & Chart Explanation */}
<div className="w-full lg:w-72 flex flex-col gap-4">
{/* 研判结论 */}
<div>
<h3 className="text-xs font-semibold text-slate-500 mb-2"></h3>
<div className="bg-blue-50 rounded-xl p-3 border border-blue-100">
<p className="text-sm text-slate-800 font-medium leading-relaxed text-center">
{getJudgmentConclusion(activeDimensionKey, activeSubTab)}
</p>
</div>
</div>
{/* 图解释 */}
<div>
<h3 className="text-xs font-semibold text-slate-500 mb-2"></h3>
<div className="bg-blue-50 rounded-xl p-3 border border-blue-100">
<p className="text-sm text-slate-800 font-medium leading-relaxed">
{getChartExplanation(activeDimensionKey, activeSubTab)}
</p>
</div>
</div>
</div>
</div>
</div>
{/* AI Modification Prompt */}
<div className="max-w-4xl mx-auto mt-6 mb-8">
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-2 focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500 transition-all">
<textarea
className="w-full p-3 bg-transparent border-none outline-none resize-none text-sm min-h-[60px]"
placeholder="请输入修改内容...例如:'帮我把时间范围改成过去5年' 或者 '添加一个PE对比图表'"
></textarea>
<div className="flex items-center justify-between px-1 pb-1">
<div className="flex items-center gap-1">
<button className="p-1.5 text-slate-400 hover:bg-slate-50 rounded-full transition-colors"><Plus size={18} /></button>
<button className="p-1.5 text-slate-400 hover:bg-slate-50 rounded-full transition-colors"><AtSign size={18} /></button>
</div>
<button className="w-8 h-8 bg-indigo-500 hover:bg-indigo-600 text-white rounded-full flex items-center justify-center shadow-md transition-colors">
<Send size={16} className="-ml-0.5 mt-0.5" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

110
pages/SearchPage.tsx Normal file
View File

@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Logo } from '../components/Logo';
import { StockItem } from '../types';
const POPULAR_STOCKS: StockItem[] = [
{ name: '贵州茅台', code: '600519.SH', change: 1.85, type: 'SH' },
];
export const SearchPage: React.FC = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [isSearching, setIsSearching] = useState(false);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchTerm.trim()) {
setIsSearching(true);
// Simulate loading
setTimeout(() => navigate('/dashboard'), 800);
}
};
const handleCardClick = () => {
navigate('/dashboard');
};
return (
<div className="min-h-screen bg-slate-50 flex flex-col">
{/* Header */}
<header className="h-16 bg-white border-b border-slate-200 px-6 flex items-center justify-between sticky top-0 z-10">
<Logo />
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium">U</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 max-w-6xl mx-auto w-full px-6 py-12 flex flex-col items-center">
{/* Search Section */}
<div className="w-full max-w-2xl mb-12 relative z-20">
<form onSubmit={handleSearch} className="relative group">
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none">
<Search className="text-slate-400" size={20} />
</div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm text-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
placeholder="搜索标的..."
/>
{searchTerm && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-xl border border-slate-100 overflow-hidden animate-in fade-in slide-in-from-top-2">
<div
onClick={() => navigate('/dashboard')}
className="px-4 py-3 hover:bg-slate-50 cursor-pointer flex items-center gap-3 border-b border-slate-50 last:border-0"
>
<div className="w-10 h-10 rounded bg-slate-100 flex items-center justify-center text-xs font-bold text-slate-600">
SH
</div>
<div>
<p className="font-bold text-slate-800"></p>
<p className="text-xs text-slate-500">600519.SH · Stock</p>
</div>
</div>
</div>
)}
</form>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{POPULAR_STOCKS.map((stock) => (
<div
key={stock.code}
onClick={handleCardClick}
className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md hover:border-blue-200 transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
{/* Simple icon based on type */}
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center text-slate-400 group-hover:text-blue-500 transition-colors">
<Search size={16} />
</div>
<span className="font-bold text-slate-700">{stock.name}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-400 font-mono">{stock.code}</span>
<span className="bg-red-50 text-red-500 px-2 py-1 rounded text-xs font-bold font-mono">
+{stock.change.toFixed(2)}%
</span>
</div>
</div>
))}
<div className="bg-white/50 border-2 border-dashed border-slate-200 p-6 rounded-2xl flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400">
{isSearching ? <Loader2 className="animate-spin" size={20} /> : <span className="text-sm">More data loading...</span>}
</div>
</div>
</div>
</main>
</div>
);
};

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

35
src/index.css Normal file
View File

@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Helvetica Neue', sans-serif;
background-color: #f8fafc;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}

29
tailwind.config.js Normal file
View File

@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./App.tsx",
"./index.tsx",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
primary: '#2563eb',
secondary: '#64748b',
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
800: '#1e293b',
900: '#0f172a',
}
}
},
},
plugins: [],
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

20
types.ts Normal file
View File

@ -0,0 +1,20 @@
export interface StockItem {
name: string;
code: string;
change: number;
type: 'SH' | 'SZ';
}
export interface DimensionData {
id: string;
title: string;
icon: string;
summary: string;
subItems: Array<{ label: string; value: string }>;
}
export interface ChartDataPoint {
date: string;
value: number;
value2?: number; // For comparison lines
}

24
vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
base: './',
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});