Initial commit

This commit is contained in:
xucheng 2025-10-21 20:17:14 +08:00
commit 4d7aa56b4b
46 changed files with 14287 additions and 0 deletions

413
.gitignore vendored Normal file
View File

@ -0,0 +1,413 @@
# ===== 通用文件 =====
# 操作系统生成的文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE 和编辑器
.vscode/
.codebuddy/
.kiro/
.idea/
*.swp
*.swo
*~
# 日志文件
*.log
logs/
# 临时文件
*.tmp
*.temp
.cache/
# ===== Python 后端 =====
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# pdm
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.env.*
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
.idea/
# ===== Node.js / Next.js 前端 =====
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.parcel-cache
# Next.js build output
.next/
out/
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
storybook-static
# Temporary folders
tmp/
temp/
# Vercel
.vercel
# Turbo
.turbo
# ===== 数据库 =====
# SQLite
*.sqlite
*.sqlite3
*.db
# PostgreSQL
*.sql
# MySQL
*.mysql
# ===== 配置和密钥 =====
# 环境变量文件
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# API 密钥和配置
config/secrets.json
secrets/
*.key
*.pem
*.p12
*.pfx
# ===== 项目特定 =====
# 数据文件
data/
*.csv
*.json.bak
*.xlsx
# 报告和输出
reports/
output/
exports/
# 备份文件
*.bak
*.backup
backup/
# 测试数据
test_data/
mock_data/
# ===== 测试文件 =====
# Python 测试文件
test_*.py
*_test.py
tests/test_*.py
tests/*_test.py
test/
tests/output/
tests/reports/
tests/coverage/
tests/screenshots/
tests/artifacts/
# JavaScript/TypeScript 测试文件
*.test.js
*.test.ts
*.test.jsx
*.test.tsx
*.spec.js
*.spec.ts
*.spec.jsx
*.spec.tsx
__tests__/
test/
tests/
*.test.snap
coverage/
.nyc_output/
# Jest 相关
jest-results.json
jest.config.local.js
jest.config.local.ts
# Cypress 测试
cypress/videos/
cypress/screenshots/
cypress/downloads/
cypress/fixtures/generated/
# Playwright 测试
test-results/
playwright-report/
playwright/.cache/
# 测试报告和覆盖率
coverage/
.coverage
htmlcov/
lcov.info
coverage.xml
coverage.json
coverage.lcov
junit.xml
test-report.xml
test-results.xml
# 性能测试
benchmark/
performance/
load-test/
# 端到端测试
e2e/screenshots/
e2e/videos/
e2e/downloads/
e2e/test-results/
# 测试配置文件(如果是临时的)
test.config.local.*
*.test.config.local.*
# 测试数据库
test.db
test.sqlite
test.sqlite3
*_test.db
# 测试日志
test.log
tests.log
test_*.log
*_test.log
# Mock 和 Stub 文件
mocks/generated/
stubs/generated/
fixtures/generated/
# 测试缓存
.pytest_cache/
.cache/pytest/
node_modules/.cache/jest/
# 外部参考资料
Reference/

View File

@ -0,0 +1,351 @@
# 设计文档 - 基本面选股系统
## 概览
基本面选股系统是一个全栈Web应用采用前后端分离架构。前端使用Next.js和shadcn/ui构建响应式中文界面后端使用Python FastAPI提供API服务。系统通过多个专业分析模块结合财务数据API、AI大模型和实时数据为用户提供全面的股票基本面分析报告。
## 架构
### 系统架构图
```mermaid
graph TB
subgraph "前端层"
A[Next.js应用] --> B[shadcn/ui UI组件]
A --> C[TradingView图表组件]
end
subgraph "后端层"
D[FastAPI服务器] --> E[报告生成引擎]
D --> F[数据获取服务]
D --> G[配置管理服务]
end
subgraph "外部服务"
H[Tushare API]
I[Gemini API]
J[其他数据源APIs]
end
subgraph "数据层"
K[PostgreSQL数据库]
end
A --> D
F --> H
F --> I
F --> J
E --> K
G --> K
```
### 技术栈
**前端:**
- Next.js 14 (App Router)
- TypeScript
- shadcn/ui组件库 (https://ui.shadcn.com/)
- TradingView Charting Library
- Tailwind CSS
- Radix UI (shadcn/ui的底层组件)
**后端:**
- Python 3.11+
- FastAPI
- SQLAlchemy (ORM)
- Alembic (数据库迁移)
- Pydantic (数据验证)
- httpx (HTTP客户端)
**数据库:**
- PostgreSQL 15+
**外部服务:**
- Tushare API (中国股票数据)
- Google Gemini API (AI分析)
- 其他市场数据源APIs
## 组件和接口
### shadcn/ui组件使用规划
系统将使用shadcn/ui (https://ui.shadcn.com/) 的官方组件来构建一致的用户界面:
**核心组件使用:**
- `Button`: 主要操作按钮(生成报告、保存配置等)
- `Input`: 证券代码输入框
- `Select`: 交易市场选择器
- `Card`: 分析模块容器、报告卡片
- `Progress`: 报告生成进度条
- `Badge`: 状态标识(完成、进行中、失败)
- `Tabs`: 分析模块切换
- `Form`: 配置表单、搜索表单
- `Alert`: 错误提示、成功消息
- `Separator`: 内容分隔线
- `Skeleton`: 加载占位符
- `Toast`: 操作反馈通知
- `Table`: 财务数据展示表格(资产负债表、利润表、现金流量表等)
**主题配置:**
- 使用默认主题,支持深色/浅色模式切换
- 自定义中文字体配置
- 适配中文内容的间距和排版
### 前端组件结构
```
src/
├── app/
│ ├── page.tsx # 首页
│ ├── report/[symbol]/page.tsx # 报告页面
│ ├── config/page.tsx # 配置页面
│ └── layout.tsx # 根布局
├── components/
│ ├── ui/ # shadcn/ui基础组件 (Button, Input, Card, etc.)
│ ├── StockSearchForm.tsx # 股票搜索表单 (使用Form, Input, Select)
│ ├── ReportProgress.tsx # 报告生成进度 (使用Progress, Badge, Card)
│ ├── TradingViewChart.tsx # TradingView图表
│ ├── AnalysisModule.tsx # 分析模块容器 (使用Card, Tabs, Separator)
│ ├── FinancialDataTable.tsx # 财务数据表格 (使用Table, TableHeader, TableBody, TableRow, TableCell)
│ └── ConfigForm.tsx # 配置表单 (使用Form, Input, Button, Alert)
├── lib/
│ ├── api.ts # API客户端
│ ├── types.ts # TypeScript类型定义
│ └── utils.ts # 工具函数
└── hooks/
├── useReport.ts # 报告数据钩子
└── useProgress.ts # 进度追踪钩子
```
### 后端API结构
```
app/
├── main.py # FastAPI应用入口
├── models/
│ ├── __init__.py
│ ├── report.py # 报告数据模型
│ ├── config.py # 配置数据模型
│ └── progress.py # 进度追踪模型
├── schemas/
│ ├── __init__.py
│ ├── report.py # 报告Pydantic模式
│ ├── config.py # 配置Pydantic模式
│ └── progress.py # 进度Pydantic模式
├── services/
│ ├── __init__.py
│ ├── data_fetcher.py # 数据获取服务
│ ├── ai_analyzer.py # AI分析服务
│ ├── report_generator.py # 报告生成服务
│ └── config_manager.py # 配置管理服务
├── routers/
│ ├── __init__.py
│ ├── reports.py # 报告相关API
│ ├── config.py # 配置相关API
│ └── progress.py # 进度相关API
└── core/
├── __init__.py
├── database.py # 数据库连接
├── config.py # 应用配置
└── dependencies.py # 依赖注入
```
### 核心API接口
#### 报告相关API
```python
# GET /api/reports/{symbol}?market={market}
# 获取或生成股票报告
class ReportResponse:
symbol: str
market: str
report_id: str
status: str # "existing" | "generating" | "completed" | "failed"
created_at: datetime
updated_at: datetime
modules: List[AnalysisModule]
# POST /api/reports/{symbol}/regenerate?market={market}
# 重新生成报告
class RegenerateRequest:
force: bool = False
# GET /api/reports/{report_id}/progress
# 获取报告生成进度
class ProgressResponse:
report_id: str
current_step: int
total_steps: int
current_step_name: str
status: str # "running" | "completed" | "failed"
step_timings: List[StepTiming]
estimated_remaining: Optional[int]
```
#### 配置相关API
```python
# GET /api/config
# 获取系统配置
class ConfigResponse:
database: DatabaseConfig
gemini_api: GeminiConfig
data_sources: Dict[str, DataSourceConfig]
# PUT /api/config
# 更新系统配置
class ConfigUpdateRequest:
database: Optional[DatabaseConfig]
gemini_api: Optional[GeminiConfig]
data_sources: Optional[Dict[str, DataSourceConfig]]
# POST /api/config/test
# 测试配置连接
class ConfigTestRequest:
config_type: str # "database" | "gemini" | "data_source"
config_data: Dict[str, Any]
```
## 数据模型
### 数据库表结构
```sql
-- 报告表
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
symbol VARCHAR(20) NOT NULL,
market VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'generating',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(symbol, market)
);
-- 分析模块表
CREATE TABLE analysis_modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID REFERENCES reports(id) ON DELETE CASCADE,
module_type VARCHAR(50) NOT NULL,
module_order INTEGER NOT NULL,
title VARCHAR(200) NOT NULL,
content JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
error_message TEXT
);
-- 进度追踪表
CREATE TABLE progress_tracking (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID REFERENCES reports(id) ON DELETE CASCADE,
step_name VARCHAR(100) NOT NULL,
step_order INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
duration_ms INTEGER,
error_message TEXT
);
-- 系统配置表
CREATE TABLE system_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_key VARCHAR(100) UNIQUE NOT NULL,
config_value JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
### 分析模块类型定义
```python
class AnalysisModuleType(Enum):
TRADING_VIEW_CHART = "trading_view_chart"
FINANCIAL_DATA = "financial_data"
BUSINESS_INFO = "business_info"
FUNDAMENTAL_ANALYSIS = "fundamental_analysis"
BULLISH_ANALYSIS = "bullish_analysis"
BEARISH_ANALYSIS = "bearish_analysis"
MARKET_ANALYSIS = "market_analysis"
NEWS_ANALYSIS = "news_analysis"
TRADING_ANALYSIS = "trading_analysis"
INSIDER_ANALYSIS = "insider_analysis"
FINAL_CONCLUSION = "final_conclusion"
class AnalysisModule(BaseModel):
id: UUID
module_type: AnalysisModuleType
title: str
content: Dict[str, Any]
status: str
duration_ms: Optional[int]
error_message: Optional[str]
```
## 错误处理
### 错误类型定义
```python
class StockAnalysisError(Exception):
"""基础异常类"""
pass
class DataSourceError(StockAnalysisError):
"""数据源错误"""
pass
class AIAnalysisError(StockAnalysisError):
"""AI分析错误"""
pass
class ConfigurationError(StockAnalysisError):
"""配置错误"""
pass
class DatabaseError(StockAnalysisError):
"""数据库错误"""
pass
```
### 错误处理策略
1. **数据获取失败**: 重试机制最多3次重试指数退避
2. **AI分析失败**: 记录错误,继续其他模块,最后汇总失败信息
3. **数据库连接失败**: 使用连接池,自动重连
4. **配置错误**: 提供详细错误信息,阻止系统启动
5. **前端错误**: Toast通知错误边界组件
## 测试策略
### 测试层级
1. **单元测试**
- 后端服务函数测试
- 前端组件测试
- 数据模型验证测试
2. **集成测试**
- API端点测试
- 数据库操作测试
- 外部服务集成测试
3. **端到端测试**
- 完整报告生成流程测试
- 用户界面交互测试
### 测试工具
- **后端**: pytest, pytest-asyncio, httpx
- **前端**: Jest, React Testing Library, Playwright
- **数据库**: pytest-postgresql
- **API测试**: FastAPI TestClient
### 测试数据
- 使用测试数据库和模拟数据
- 外部API使用mock响应
- 测试用例覆盖各种市场和股票类型

View File

@ -0,0 +1,112 @@
# 需求文档 - MVP版本
## 介绍
基本面选股系统MVP是一个综合的中文网站允许用户输入证券代码和交易市场生成包含多维度分析的详细股票基本面报告。系统通过多个专业分析模块结合财务数据、AI分析和市场信息为用户提供全面的投资决策支持。
## 术语表
- **选股系统 (Stock_Selection_System)**: 提供基本面分析和报告生成的主要系统
- **用户 (User)**: 使用系统进行股票分析的终端用户
- **证券代码 (Security_Code)**: 股票在特定交易市场的唯一标识符
- **交易市场 (Trading_Market)**: 股票交易的地理区域,包括中国、香港、美国、日本
- **基本面报告 (Fundamental_Report)**: 包含九个分析模块的综合股票分析报告
- **TradingView图表 (TradingView_Chart)**: 使用TradingView高级图表组件显示的股价图表
- **Tushare_API**: 用于获取中国股票财务数据的数据源接口
- **Gemini_Model**: Google的大语言模型用于生成业务分析内容
- **景林模型 (Jinglin_Model)**: 基本面分析师使用的问题集分析框架
- **PostgreSQL数据库 (PostgreSQL_Database)**: 用于存储报告数据的关系型数据库
- **分析模块 (Analysis_Module)**: 报告中的独立分析部分,每个模块对应一个显示页面
## 需求
### 需求 1
**用户故事:** 作为投资者,我希望能够输入股票代码和选择交易市场,以便获取该股票的综合基本面分析报告
#### 验收标准
1. 当用户访问首页时,选股系统应当显示证券代码输入框和交易市场选择器
2. 当用户选择交易市场时,选股系统应当提供中国、香港、美国、日本四个选项
3. 当用户提交证券代码和交易市场时,选股系统应当处理用户请求并跳转到报告页面
### 需求 2
**用户故事:** 作为投资者,我希望系统能够检查历史报告,以便决定是查看现有报告还是生成新报告
#### 验收标准
1. 当用户提交证券代码和交易市场后选股系统应当在PostgreSQL数据库中查询对应的历史报告
2. 如果存在历史报告,选股系统应当显示历史报告内容和"生成最新报告"按钮
3. 如果不存在历史报告,选股系统应当自动启动九步报告生成流程
### 需求 3
**用户故事:** 作为投资者,我希望系统能够获取准确的财务数据,以便进行可靠的基本面分析
#### 验收标准
1. 当生成中国股票报告时选股系统应当使用Tushare_API获取财务信息
2. 当处理其他市场股票时,选股系统应当根据交易市场选择相应的数据源
3. 当财务数据获取完成时,选股系统应当将数据作为后续分析的基础
### 需求 4
**用户故事:** 作为投资者我希望系统能够通过AI分析获取公司业务信息以便了解公司的全面情况
#### 验收标准
1. 当需要业务信息时选股系统应当使用Gemini生成公司概览、主营业务、发展历程、核心团队、供应链、主要客户及销售模式、未来展望
2. 当调用Gemini_Model时选股系统应当使用配置的API密钥进行身份验证
3. 当业务信息生成完成时,选股系统应当将内容整合到报告的第二部分
### 需求 5
**用户故事:** 作为投资者,我希望系统能够提供多维度的专业分析,以便获得全面的投资决策支持
#### 验收标准
1. 当生成报告时选股系统应当按顺序执行10个分析模块财务信息、业务信息、基本面分析、看涨分析、看跌分析、市场分析、新闻分析、交易分析、内部人与机构动向分析、最终结论
2. 当执行基本面分析时,选股系统应当使用问题集进行分析
3. 当执行看涨分析时,选股系统应当研究潜在隐藏资产和护城河竞争优势
4. 当执行看跌分析时,选股系统应当分析公司价值底线和最坏情况
5. 当执行市场分析时,选股系统应当研究市场情绪分歧点与变化驱动
6. 当执行新闻分析时,选股系统应当研究股价催化剂与拐点预判
7. 当执行交易分析时,选股系统应当研究市场体量与增长路径
8. 当执行内部人分析时,选股系统应当研究内部人与机构动向
9. 当生成最终结论时,选股系统应当指出关键矛盾与预期差以及拐点的临近
### 需求 6
**用户故事:** 作为投资者,我希望每个分析模块都能独立查看,以便专注于特定的分析维度
#### 验收标准
1. 当显示报告时,选股系统应当为每个分析模块提供独立的显示页面
2. 当用户在模块间切换时,选股系统应当保持导航的流畅性
3. 当所有模块完成时选股系统应当将完整报告保存到PostgreSQL数据库
### 需求 7
**用户故事:** 作为投资者,我希望在报告生成过程中能够看到实时进度,以便了解当前状态和预估完成时间
#### 验收标准
1. 当开始生成报告时,选股系统应当显示进度指示器展示所有分析步骤
2. 当执行每个分析步骤时,选股系统应当高亮显示当前正在进行的步骤
3. 当每个步骤完成时,选股系统应当更新步骤状态为已完成
4. 当执行分析步骤时,选股系统应当记录每个步骤的开始时间和完成时间
5. 当显示进度时,选股系统应当展示每个步骤的耗时统计
6. 当步骤执行失败时,选股系统应当显示错误状态和错误信息
### 需求 8
**用户故事:** 作为系统管理员,我希望能够配置系统参数,以便系统能够正常连接外部服务
#### 验收标准
1. 选股系统应当提供配置页面用于设置数据库连接参数
2. 选股系统应当提供配置页面用于设置Gemini_API密钥
3. 选股系统应当提供配置页面用于设置各市场的数据源配置
4. 当配置更新时,选股系统应当验证配置的有效性
5. 当配置保存时,选股系统应当将配置持久化存储

View File

@ -0,0 +1,167 @@
# 实施计划
- [x] 1. 后端项目初始化和基础架构
- 创建Python FastAPI项目结构
- 设置虚拟环境和依赖管理requirements.txt或pyproject.toml
- 配置FastAPI应用入口main.py
- 创建核心目录结构models, schemas, services, routers, core
- 设置基础配置管理core/config.py
- _需求: 8.1, 8.2_
- [x] 2. 数据库设置和模型定义
- 配置PostgreSQL数据库连接core/database.py
- 创建SQLAlchemy数据模型reports, analysis_modules, progress_tracking, system_config
- 设置Alembic数据库迁移工具
- 创建初始数据库迁移脚本
- 实现数据库会话管理和依赖注入
- _需求: 6.3, 8.1_
- [x] 3. Pydantic模式和基础服务
- 创建Pydantic数据验证模式schemas/
- 实现配置管理服务services/config_manager.py
- 创建数据获取服务基础架构services/data_fetcher.py
- 实现基础错误处理和异常类
- _需求: 8.2, 8.3, 8.4, 8.5_
- [x] 4. 外部API集成服务
- 实现Tushare API集成中国股票数据获取
- 实现Gemini API集成AI分析服务
- 创建数据源配置和切换逻辑
- 添加API调用错误处理和重试机制
- _需求: 3.1, 3.2, 4.1, 4.2_
- [x] 5. 报告生成引擎核心
- 创建报告生成服务services/report_generator.py
- 实现分析模块执行框架
- 创建进度追踪服务services/progress_tracker.py
- 实现步骤计时和状态管理
- _需求: 5.1, 7.1, 7.2, 7.3, 7.4, 7.5_
- [x] 6. 后端API路由实现
- 实现报告相关API端点routers/reports.py
- 创建配置管理API端点routers/config.py
- 实现进度追踪API端点routers/progress.py
- 添加API文档和验证
- _需求: 2.1, 2.2, 2.3, 8.1, 8.2, 8.3_
- [x] 7. 前端项目初始化
- 创建Next.js项目并配置TypeScript
- 安装和配置shadcn/ui组件库
- 设置Tailwind CSS和基础样式
- 配置项目文件夹结构components, lib, hooks, app
- 创建基础布局和主题配置
- _需求: 1.1_
- [x] 8. 前端核心组件开发
- 安装和配置shadcn/ui基础组件
- 实现StockSearchForm组件使用Form, Input, Select, Button
- 创建ReportProgress组件使用Progress, Badge, Card
- 实现AnalysisModule组件使用Card, Tabs, Separator
- 创建FinancialDataTable组件使用Table组件系列
- _需求: 1.1, 1.2, 7.1, 7.2_
- [x] 9. 首页和股票搜索功能
- 实现首页布局和设计app/page.tsx
- 创建股票代码输入和市场选择功能
- 实现表单验证和提交逻辑
- 添加中文界面文本和错误提示
- 连接前端表单到后端API
- _需求: 1.1, 1.2, 1.3_
- [x] 10. 报告页面和历史报告功能
- 实现报告页面路由app/report/[symbol]/page.tsx
- 创建历史报告检查和显示逻辑
- 实现"生成最新报告"按钮功能
- 添加报告加载状态和错误处理
- _需求: 2.1, 2.2, 2.3_
- [x] 11. TradingView图表集成
- 集成TradingView高级图表组件
- 实现图表配置和参数设置
- 根据证券代码和市场配置图表
- 处理图表加载错误和异常情况
- _需求: 5.1, 5.2, 5.3, 5.4_
- [x] 12. 财务数据分析模块
- 实现财务数据获取和处理逻辑
- 创建财务数据格式化和展示
- 实现FinancialDataTable的数据绑定
- 添加财务数据的错误处理和重试
- _需求: 3.1, 3.2, 3.3_
- [x] 13. AI业务信息分析模块
- 实现Gemini API调用逻辑和提示词模板
- 创建业务信息分析内容生成
- 实现公司概览、主营业务、发展历程等内容
- 添加AI分析结果的格式化和展示
- _需求: 4.1, 4.2, 4.3_
- [x] 14. 专业分析模块实现
- 实现景林模型基本面分析模块
- 创建看涨分析师模块(隐藏资产、护城河分析)
- 实现看跌分析师模块(价值底线、最坏情况分析)
- 创建市场分析师模块(市场情绪分歧点分析)
- 实现新闻分析师模块(股价催化剂分析)
- 创建交易分析模块(市场体量与增长路径)
- 实现内部人与机构动向分析模块
- 创建最终结论模块(关键矛盾与拐点分析)
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9_
- [x] 15. 报告生成流程整合
- 整合所有分析模块到报告生成引擎
- 实现模块间的数据传递和依赖关系
- 创建报告生成的错误处理和重试机制
- 实现报告完成后的数据库保存
- _需求: 5.1, 6.3_
- [x] 16. 实时进度显示功能
- 实现前端进度追踪钩子useProgress
- 连接WebSocket或Server-Sent Events到进度显示
- 添加步骤高亮和状态更新
- 实现计时显示和预估完成时间
- 添加错误状态显示
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
- [x] 17. 配置管理页面
- 创建配置页面布局和表单app/config/page.tsx
- 实现数据库配置界面
- 添加Gemini API配置功能
- 创建数据源配置管理
- 实现配置验证和测试功能
- _需求: 8.1, 8.2, 8.3, 8.4, 8.5_
- [x] 18. 报告展示和导航优化
- 实现分析模块的独立页面展示
- 创建模块间的流畅导航
- 添加报告概览和目录功能
- 优化移动端响应式显示
- _需求: 6.1, 6.2_
- [x] 19. 错误处理和用户体验优化
- 实现全局错误处理和错误边界
- 添加Toast通知系统
- 创建加载状态和骨架屏
- 优化中文界面和用户反馈
- 添加操作确认和提示
- _需求: 7.6, 1.1_
- [x] 20. 测试实现
- [x] 20.1 后端单元测试
- 为数据获取服务编写单元测试
- 为AI分析服务编写单元测试
- 为报告生成引擎编写单元测试
- 为配置管理服务编写单元测试
- [x] 20.2 前端组件测试
- 为核心组件编写React Testing Library测试
- 为表单组件编写交互测试
- 为进度组件编写状态测试
- [x] 20.3 API集成测试
- 为报告生成API编写集成测试
- 为配置管理API编写集成测试
- 为进度追踪API编写集成测试
- [x] 20.4 端到端测试
- 编写完整报告生成流程的E2E测试
- 编写配置管理流程的E2E测试

20
config/config.json Normal file
View File

@ -0,0 +1,20 @@
{
"llm": {
"provider": "gemini",
"gemini": {
"base_url": "",
"api_key": "AIzaSyCe4KpiRWFU3hnP-iwWvDR28ZCEzFnN0x0"
}
},
"data_sources": {
"tushare": {
"api_key": "f62b415de0a5a947fcb693b66cd299dd6242868bf04ad687800c7f3f"
},
"finnhub": {
"api_key": "d3fjs5pr01qolkndil0gd3fjs5pr01qolkndil10"
}
},
"database": {
"url": "postgresql+asyncpg://value:Value609!@192.168.3.195:5432/fundamental"
}
}

View File

@ -0,0 +1,62 @@
{
"api_groups": {
"fina_indicator": [
{ "displayText": "ROE", "tushareParam": "roe", "api": "fina_indicator" },
{ "displayText": "ROA", "tushareParam": "roa", "api": "fina_indicator" },
{ "displayText": "ROCE/ROIC", "tushareParam": "roic", "api": "fina_indicator" },
{ "displayText": "毛利率", "tushareParam": "grossprofit_margin", "api": "fina_indicator" },
{ "displayText": "净利润率", "tushareParam": "netprofit_margin", "api": "fina_indicator" },
{ "displayText": "税率", "tushareParam": "tax_to_ebt", "api": "fina_indicator" },
{ "displayText": "负债率", "tushareParam": "debt_to_assets", "api": "fina_indicator" },
{ "displayText": "总资产周转率", "tushareParam": "assets_turn", "api": "fina_indicator" },
{ "displayText": "收入增速", "tushareParam": "tr_yoy", "api": "fina_indicator" },
{ "displayText": "净利润增速", "tushareParam": "dt_netprofit_yoy", "api": "fina_indicator" },
{ "displayText": "库存天数", "tushareParam": "invturn_days", "api": "fina_indicator" },
{ "displayText": "应收款周转天数", "tushareParam": "arturn_days", "api": "fina_indicator" },
{ "displayText": "固定资产周转率", "tushareParam": "fa_turn", "api": "fina_indicator" }
],
"income": [
{ "displayText": "收入", "tushareParam": "revenue", "api": "income" },
{ "displayText": "净利润", "tushareParam": "n_income", "api": "income" },
{ "displayText": "销售费用", "tushareParam": "sell_exp", "api": "income" },
{ "displayText": "管理费用", "tushareParam": "admin_exp", "api": "income" },
{ "displayText": "研发费用", "tushareParam": "rd_exp", "api": "income" }
],
"balancesheet": [
{ "displayText": "总资产", "tushareParam": "total_assets", "api": "balancesheet" },
{ "displayText": "库存", "tushareParam": "inventories", "api": "balancesheet" },
{ "displayText": "应收款", "tushareParam": "accounts_receiv_bill", "api": "balancesheet" },
{ "displayText": "预付款", "tushareParam": "prepayment", "api": "balancesheet" },
{ "displayText": "固定资产", "tushareParam": "fix_assets", "api": "balancesheet" },
{ "displayText": "应付款", "tushareParam": "accounts_pay", "api": "balancesheet" },
{ "displayText": "商誉", "tushareParam": "goodwill", "api": "balancesheet" },
{ "displayText": "预收款", "tushareParam": "adv_receipts", "api": "balancesheet" },
{ "displayText": "合同负债", "tushareParam": "contract_liab", "api": "balancesheet" },
{ "displayText": "净资产", "tushareParam": "total_hldr_eqy_exc_min_int", "api": "balancesheet" },
{ "displayText": "现金", "tushareParam": "money_cap", "api": "balancesheet" },
{ "displayText": "长期投资", "tushareParam": "lt_eqt_invest", "api": "balancesheet" },
{ "displayText": "短期借款", "tushareParam": "st_borr", "api": "balancesheet" },
{ "displayText": "长期借款", "tushareParam": "lt_borr", "api": "balancesheet" }
],
"cashflow": [
{ "displayText": "经营净现金流", "tushareParam": "n_cashflow_act", "api": "cashflow" },
{ "displayText": "资本开支", "tushareParam": "c_pay_acq_const_fiolta", "api": "cashflow" },
{ "displayText": "折旧费用", "tushareParam": "depr_fa_coga_dpba", "api": "cashflow" }
],
"daily_basic": [
{ "displayText": "PB", "tushareParam": "pb", "api": "daily_basic" },
{ "displayText": "市值", "tushareParam": "total_mv", "api": "daily_basic" },
{ "displayText": "PE", "tushareParam": "pe", "api": "daily_basic" }
],
"daily": [
{ "displayText": "收盘价", "tushareParam": "close", "api": "daily" }
],
"unknown": [
{ "displayText": "每股分红", "tushareParam": "cash_div_tax", "api": "dividend" },
{ "displayText": "基准股本", "tushareParam": "base_share", "api": "dividend" },
{ "displayText": "员工数", "tushareParam": "employees", "api": "stock_company" },
{ "displayText": "股东数", "tushareParam": "holder_num", "api": "stk_holdernumber" },
{ "displayText": "境外收入占比", "tushareParam": "", "api": "" }
]
}
}

206
dev.py Executable file
View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
import os
import sys
import shutil
import signal
import subprocess
import threading
import time
from pathlib import Path
import argparse
def which(cmd: str):
return shutil.which(cmd)
def is_executable(p: Path):
return p.exists() and os.access(str(p), os.X_OK)
def build_cmd_display(cmd):
return " ".join(cmd) if isinstance(cmd, (list, tuple)) else str(cmd)
def pick_python_and_uvicorn(repo_root: Path):
venv_bin = repo_root / ".venv" / "bin"
candidates = {}
# Pick python
venv_python = venv_bin / "python"
if is_executable(venv_python):
candidates["python"] = str(venv_python)
else:
candidates["python"] = which("python3") or which("python") or sys.executable
# Pick uvicorn
venv_uvicorn = venv_bin / "uvicorn"
if is_executable(venv_uvicorn):
candidates["uvicorn_mode"] = "binary"
candidates["uvicorn"] = str(venv_uvicorn)
else:
sys_uvicorn = which("uvicorn")
if sys_uvicorn:
candidates["uvicorn_mode"] = "binary"
candidates["uvicorn"] = sys_uvicorn
else:
candidates["uvicorn_mode"] = "module"
candidates["uvicorn"] = candidates["python"]
return candidates
def stream_output(proc, prefix):
# ANSI colors: backend -> green, frontend -> cyan, others -> yellow
RESET = "\033[0m"
COLOR = "\033[36m" if prefix == "frontend" else ("\033[32m" if prefix == "backend" else "\033[33m")
for line in iter(proc.stdout.readline, b""):
if not line:
break
try:
decoded = line.decode(errors="replace")
sys.stdout.write(f"{COLOR}[{prefix}]{RESET} {decoded}")
except Exception:
sys.stdout.write(f"{COLOR}[{prefix}]{RESET} {line!r}\n")
sys.stdout.flush()
def start_process(cmd, cwd, prefix, env=None):
proc = subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env or os.environ.copy(),
text=False,
bufsize=1,
)
t = threading.Thread(target=stream_output, args=(proc, prefix), daemon=True)
t.start()
return proc
def terminate_process(proc, name, timeout=8):
if proc.poll() is not None:
return
try:
proc.terminate()
except Exception:
pass
for _ in range(timeout * 10):
if proc.poll() is not None:
return
time.sleep(0.1)
try:
proc.kill()
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description="Dev runner: backend (FastAPI/uvicorn) + frontend (Next.js)")
parser.add_argument("--backend-host", default=os.getenv("BACKEND_HOST", "127.0.0.1"))
parser.add_argument("--backend-port", default=os.getenv("BACKEND_PORT", "8000"))
parser.add_argument("--no-frontend", action="store_true", help="Start backend only")
parser.add_argument("--no-backend", action="store_true", help="Start frontend only")
parser.add_argument("--frontend-cmd", default=os.getenv("FRONTEND_CMD", "npm run dev"))
parser.add_argument("--backend-app", default=os.getenv("BACKEND_APP", "main:app"), help="Uvicorn app path, e.g. main:app")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parent
backend_dir = repo_root / "backend"
frontend_dir = repo_root / "frontend"
picks = pick_python_and_uvicorn(repo_root)
py_path = picks["python"]
uvicorn_mode = picks["uvicorn_mode"]
uvicorn_ref = picks["uvicorn"]
print("======== dev runner ========")
print(f"Repo root: {repo_root}")
print(f"Python: {py_path}")
if uvicorn_mode == "binary":
print(f"Uvicorn: {uvicorn_ref} (binary)")
else:
print(f"Uvicorn: {uvicorn_ref} -m uvicorn (module)")
print("============================")
procs = []
# Backend
if not args.no_backend:
if uvicorn_mode == "binary":
backend_cmd = [
uvicorn_ref,
args.backend_app,
"--reload",
"--host", args.backend_host,
"--port", args.backend_port,
]
else:
backend_cmd = [
py_path, "-m", "uvicorn",
args.backend_app,
"--reload",
"--host", args.backend_host,
"--port", args.backend_port,
]
print(f"Starting backend: {build_cmd_display(backend_cmd)} (cwd={backend_dir})")
procs.append(("backend", start_process(backend_cmd, backend_dir, "backend")))
# Frontend
if not args.no_frontend:
frontend_env = os.environ.copy()
frontend_cmd = args.frontend_cmd
print(f"Starting frontend: {frontend_cmd} (cwd={frontend_dir})")
proc_fe = subprocess.Popen(
frontend_cmd,
cwd=frontend_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=frontend_env,
shell=True,
text=False,
bufsize=1,
)
t = threading.Thread(target=stream_output, args=(proc_fe, "frontend"), daemon=True)
t.start()
procs.append(("frontend", proc_fe))
def handle_signal(signum, frame):
print("\nCaught signal, terminating children...")
for name, p in procs:
terminate_process(p, name)
sys.exit(0)
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, handle_signal)
except Exception:
pass
exit_code = 0
try:
while True:
all_done = True
for name, p in procs:
ret = p.poll()
if ret is None:
all_done = False
else:
if ret != 0:
exit_code = ret
if all_done:
break
time.sleep(0.3)
except KeyboardInterrupt:
handle_signal(signal.SIGINT, None)
for name, p in procs:
terminate_process(p, name)
sys.exit(exit_code)
if __name__ == "__main__":
main()

158
docs/design.md Normal file
View File

@ -0,0 +1,158 @@
# 设计文档 - 基本面选股系统 MVP
## 1. 引言
### 1.1. 文档目的
本文档旨在根据《需求文档 - MVP版本》的要求提供一个全面的系统设计方案。它将详细阐述系统架构、模块划分、技术选型、数据库设计和API接口作为开发团队进行系统实现的核心技术指导文件。
### 1.2. 项目概述
基本面选股系统是一个Web应用旨在为投资者提供自动化、多维度的股票基本面分析报告。用户通过输入股票代码和市场可以获取或生成一份包含财务数据、AI业务分析、市场情绪、风险评估等多个模块的综合报告以辅助投资决策。
### 1.3. 范围
本设计涵盖了从用户界面到后端服务、再到数据库的全栈技术方案。主要包括:
- **前端**:用户交互界面,包括报告查询、展示、进度追踪和系统配置。
- **后端**核心业务逻辑包括报告生成、数据获取、AI分析、任务管理和配置服务。
- **数据库**:报告数据、分析模块内容、进度信息和系统配置的持久化存储。
## 2. 系统架构
### 2.1. 架构概述
系统采用前后端分离的现代化Web架构
- **前端 (Frontend)**基于React (Next.js) 的单页面应用 (SPA)负责用户界面和交互逻辑。它通过RESTful API与后端通信。
- **后端 (Backend)**基于Python FastAPI框架的异步API服务负责处理所有业务逻辑、数据操作和与外部服务的集成。
- **数据库 (Database)**采用PostgreSQL作为关系型数据库存储所有持久化数据。
- **异步任务队列**: 利用FastAPI的`BackgroundTasks`处理耗时的报告生成任务避免阻塞API请求并允许实时进度追踪。
![System Architecture Diagram](https://i.imgur.com/example.png) <!-- Placeholder for a real diagram -->
### 2.2. 技术选型
| 层次 | 技术 | 理由 |
| :--- | :--- | :--- |
| **前端** | React (Next.js), TypeScript, Shadcn/UI | 提供优秀的开发体验、类型安全、高性能的服务端渲染(SSR)和丰富的UI组件库。 |
| **后端** | Python, FastAPI, SQLAlchemy (Async) | 异步框架带来高并发性能Python拥有强大的数据处理和AI生态SQLAlchemy提供强大的ORM能力。 |
| **数据库** | PostgreSQL | 功能强大、稳定可靠的开源关系型数据库支持JSONB等高级数据类型适合存储结构化报告数据。 |
| **数据源** | Tushare API, Yahoo Finance | Tushare提供全面的中国市场数据Yahoo Finance作为其他市场的补充易于集成。 |
| **AI模型** | Google Gemini | 强大的大语言模型,能够根据指令生成高质量的业务分析内容。 |
## 3. 后端设计 (Backend Design)
### 3.1. 核心服务设计
后端逻辑将围绕以下几个核心服务展开:
- **ReportGenerator (报告生成器)**: 核心服务,负责编排整个报告生成流程。它接收报告请求,并按顺序调用各个分析模块。
- **AnalysisModule (分析模块)**: 定义一个基础接口或抽象类,每个具体的分析(如基本面、看涨/看跌分析等)都将实现此接口。这使得系统易于扩展新的分析维度。
- **DataSourceManager (数据源管理器)**: 封装对Tushare、Yahoo等外部数据API的调用提供统一的数据获取接口并处理认证、重试和缓存逻辑。
- **AIService (AI服务)**: 封装对Gemini API的调用负责发送Prompt、处理响应和管理Token消耗的记录。
- **ConfigManager (配置管理器)**: 负责从`config.json`或数据库中加载、更新和验证系统配置如API密钥、数据库URL
- **ProgressTracker (进度追踪器)**: 负责在报告生成的每个阶段更新状态、耗时和Token使用情况并提供给前端查询。
### 3.2. 异步任务处理
报告生成是一个耗时操作将通过FastAPI的`BackgroundTasks`在后台执行。当用户请求生成新报告时API会立即返回一个报告ID和“生成中”的状态并将生成任务添加到后台队列。前端可以通过该ID轮询或使用WebSocket/SSE来获取实时进度。
### 3.3. API 端点设计
后端将提供以下RESTful API端点
| Method | Endpoint | 描述 | 请求体/参数 | 响应体 |
| :--- | :--- | :--- | :--- | :--- |
| `POST` | `/api/reports` | 创建或获取报告。如果报告已存在,返回现有报告;否则,启动后台任务生成新报告。 | `symbol`, `market` | `ReportResponse` |
| `POST` | `/api/reports/regenerate` | 强制重新生成报告。 | `symbol`, `market` | `ReportResponse` |
| `GET` | `/api/reports/{report_id}` | 获取特定报告的详细内容,包括所有分析模块。 | `report_id` (UUID) | `ReportResponse` |
| `GET` | `/api/reports` | 获取报告列表,支持分页和筛选。 | `skip`, `limit`, `status` | `List[ReportResponse]` |
| `GET` | `/api/progress/stream/{report_id}` | (SSE) 实时流式传输报告生成进度。 | `report_id` (UUID) | `ProgressResponse` (Stream) |
| `GET` | `/api/config` | 获取当前系统所有配置。 | - | `ConfigResponse` |
| `PUT` | `/api/config` | 更新系统配置。 | `ConfigUpdateRequest` | `ConfigResponse` |
| `POST`| `/api/config/test` | 测试特定配置的有效性(如数据库连接)。 | `ConfigTestRequest` | `ConfigTestResponse` |
## 4. 数据库设计
### 4.1. 数据模型 (Schema)
**1. `reports` (报告表)**
存储报告的元数据。
| 字段名 | 类型 | 描述 | 示例 |
| :--- | :--- | :--- | :--- |
| `id` | UUID (Primary Key) | 报告的唯一标识符 | `uuid.uuid4()` |
| `symbol` | VARCHAR | 股票代码 | "600519" |
| `market` | VARCHAR | 交易市场 | "china" |
| `status` | VARCHAR | 生成状态 (generating, completed, failed) | "completed" |
| `created_at` | TIMESTAMPTZ | 创建时间 | `datetime.utcnow()` |
| `updated_at` | TIMESTAMPTZ | 最后更新时间 | `datetime.utcnow()` |
**2. `analysis_modules` (分析模块表)**
存储每个报告的具体分析模块内容。
| 字段名 | 类型 | 描述 | 示例 |
| :--- | :--- | :--- | :--- |
| `id` | UUID (Primary Key) | 模块的唯一标识符 | `uuid.uuid4()` |
| `report_id` | UUID (Foreign Key) | 关联的报告ID | `reports.id` |
| `module_type` | VARCHAR | 模块类型 (e.g., "business_info", "bull_case") | "bull_case" |
| `content` | JSONB | 模块的分析结果,结构化数据 | `{"title": "看涨分析", "data": [...]}` |
| `status` | VARCHAR | 模块生成状态 (pending, running, completed, failed) | "completed" |
| `error_message`| TEXT | 失败时的错误信息 | "API call failed" |
**3. `progress_tracking` (进度追踪表)**
记录报告生成过程中每个步骤的状态和性能指标。
| 字段名 | 类型 | 描述 | 示例 |
| :--- | :--- | :--- | :--- |
| `id` | UUID (Primary Key) | 进度记录的唯一标识符 | `uuid.uuid4()` |
| `report_id` | UUID (Foreign Key) | 关联的报告ID | `reports.id` |
| `step_name` | VARCHAR | 步骤名称 | "获取财务数据" |
| `status` | VARCHAR | 步骤状态 (pending, running, completed, failed) | "completed" |
| `started_at` | TIMESTAMPTZ | 步骤开始时间 | `datetime.utcnow()` |
| `completed_at` | TIMESTAMPTZ | 步骤完成时间 | `datetime.utcnow()` |
| `duration_ms` | INTEGER | 步骤耗时(毫秒) | 1500 |
| `token_usage` | INTEGER | AI步骤的Token消耗量 | 2500 |
| `error_message`| TEXT | 失败时的错误信息 | "Data source timeout" |
**4. `system_config` (系统配置表)**
以键值对形式存储可动态修改的系统配置。
| 字段名 | 类型 | 描述 | 示例 |
| :--- | :--- | :--- | :--- |
| `config_key` | VARCHAR (Primary Key) | 配置项的键 | "gemini_api_key" |
| `config_value`| JSONB | 配置项的值 | `{"api_key": "..."}` |
### 4.2. 关系
- `reports``analysis_modules` 是一对多关系。
- `reports``progress_tracking` 是一对多关系。
## 5. 前端设计 (Frontend Design)
### 5.1. 组件设计
- **`StockInputForm`**: 首页的核心组件,包含证券代码输入框和交易市场选择器。
- **`ReportPage`**: 报告的主页面,根据报告状态显示历史报告、进度追踪器或完整的分析模块。
- **`ProgressTracker`**: 实时进度组件通过订阅SSE或定时轮询来展示报告生成的步骤、状态和耗时。
- **`ModuleNavigator`**: 报告页面的侧边栏或顶部导航,允许用户在不同的分析模块间切换。
- **`ModuleViewer`**: 用于展示单个分析模块内容的组件,能渲染从`content` (JSONB)字段解析出的文本、图表和表格。
- **`ConfigPage`**: 系统配置页面提供表单来修改和测试数据库、API密钥等配置。
### 5.2. 页面与路由
- `/`: 首页,展示`StockInputForm`。
- `/report/{symbol}`: 报告页面,动态路由,根据查询参数(如`market`)加载`ReportPage`。
- `/report/{symbol}/{moduleId}`: 模块详情页,展示特定分析模块的内容。
- `/config`: 系统配置页面,展示`ConfigPage`。
### 5.3. 状态管理
- 使用Zustand或React Context进行全局状态管理主要管理用户信息、系统配置和当前的报告状态。
- 组件内部状态将使用React的`useState`和`useReducer`。
- 使用React Query或SWR来管理API数据获取、缓存和同步简化数据获取逻辑并提升用户体验。

111
docs/requirements.md Normal file
View File

@ -0,0 +1,111 @@
# 需求文档 - MVP版本
## 介绍
基本面选股系统MVP是一个综合的中文网站允许用户输入证券代码和交易市场生成包含多维度分析的详细股票基本面报告。系统通过多个专业分析模块结合财务数据、AI分析和市场信息为用户提供全面的投资决策支持。
## 术语表
- **选股系统 (Stock_Selection_System)**: 提供基本面分析和报告生成的主要系统
- **用户 (User)**: 使用系统进行股票分析的终端用户
- **证券代码 (Security_Code)**: 股票在特定交易市场的唯一标识符
- **交易市场 (Trading_Market)**: 股票交易的地理区域,包括中国、香港、美国、日本
- **基本面报告 (Fundamental_Report)**: 包含九个分析模块的综合股票分析报告
- **TradingView图表 (TradingView_Chart)**: 使用TradingView高级图表组件显示的股价图表
- **Tushare_API**: 用于获取中国股票财务数据的数据源接口
- **Gemini_Model**: Google的大语言模型用于生成业务分析内容
- **PostgreSQL数据库 (PostgreSQL_Database)**: 用于存储报告数据的关系型数据库
- **分析模块 (Analysis_Module)**: 报告中的独立分析部分,每个模块对应一个显示页面
## 需求
### 需求 1
**用户故事:** 作为投资者,我希望能够输入股票代码和选择交易市场,以便获取该股票的综合基本面分析报告
#### 验收标准
1. 当用户访问首页时,选股系统应当显示证券代码输入框和交易市场选择器
2. 当用户选择交易市场时,选股系统应当提供中国、香港、美国、日本四个选项
3. 当用户提交证券代码和交易市场时,选股系统应当处理用户请求并跳转到报告页面
### 需求 2
**用户故事:** 作为投资者,我希望系统能够检查历史报告,以便决定是查看现有报告还是生成新报告
#### 验收标准
1. 当用户提交证券代码和交易市场后选股系统应当在PostgreSQL数据库中查询对应的历史报告
2. 如果存在历史报告,选股系统应当显示历史报告内容和"生成最新报告"按钮
3. 如果不存在历史报告,选股系统应当自动启动九步报告生成流程
### 需求 3
**用户故事:** 作为投资者,我希望系统能够获取准确的财务数据,以便进行可靠的基本面分析
#### 验收标准
1. 当生成中国股票报告时选股系统应当使用Tushare_API获取财务信息
2. 当处理其他市场股票时,选股系统应当根据交易市场选择相应的数据源
3. 当财务数据获取完成时,选股系统应当将数据作为后续分析的基础
### 需求 4
**用户故事:** 作为投资者我希望系统能够通过AI分析获取公司业务信息以便了解公司的全面情况
#### 验收标准
1. 当需要业务信息时选股系统应当使用Gemini生成公司概览、主营业务、发展历程、核心团队、供应链、主要客户及销售模式、未来展望
2. 当调用Gemini时选股系统应当使用配置的API密钥进行身份验证
3. 当业务信息生成完成时,选股系统应当将内容整合到报告的第二部分
### 需求 5
**用户故事:** 作为投资者,我希望系统能够提供多维度的专业分析,以便获得全面的投资决策支持
#### 验收标准
1. 当生成报告时选股系统应当按顺序执行8个分析模块基本面分析、看涨分析、看跌分析、市场分析、新闻分析、交易分析、内部人与机构动向分析、最终结论
2. 当执行基本面分析时,选股系统应当使用问题集进行分析
3. 当执行看涨分析时,选股系统应当研究潜在隐藏资产和护城河竞争优势
4. 当执行看跌分析时,选股系统应当分析公司价值底线和最坏情况
5. 当执行市场分析时,选股系统应当研究市场情绪分歧点与变化驱动
6. 当执行新闻分析时,选股系统应当研究股价催化剂与拐点预判
7. 当执行交易分析时,选股系统应当研究市场体量与增长路径
8. 当执行内部人分析时,选股系统应当研究内部人与机构动向
9. 当生成最终结论时,选股系统应当指出关键矛盾与预期差以及拐点的临近
### 需求 6
**用户故事:** 作为投资者,我希望每个分析模块都能独立查看,以便专注于特定的分析维度
#### 验收标准
1. 当显示报告时,选股系统应当为每个分析模块提供独立的显示页面
2. 当用户在模块间切换时,选股系统应当保持导航的流畅性
3. 当所有模块完成时选股系统应当将完整报告保存到PostgreSQL数据库
### 需求 7
**用户故事:** 作为投资者,我希望在报告生成过程中能够看到实时进度,以便了解当前状态和预估完成时间
#### 验收标准
1. 当开始生成报告时,选股系统应当显示进度指示器展示所有分析步骤
2. 当执行每个分析步骤时,选股系统应当高亮显示当前正在进行的步骤
3. 当每个步骤完成时,选股系统应当更新步骤状态为已完成
4. 当执行分析步骤时选股系统应当记录每个步骤的开始时间和完成时间如果使用AI记录使用token
5. 当显示进度时,选股系统应当展示每个步骤的耗时统计
6. 当步骤执行失败时,选股系统应当显示错误状态和错误信息
### 需求 8
**用户故事:** 作为系统管理员,我希望能够配置系统参数,以便系统能够正常连接外部服务
#### 验收标准
1. 选股系统应当提供配置页面用于设置数据库连接参数
2. 选股系统应当提供配置页面用于设置Gemini_API密钥
3. 选股系统应当提供配置页面用于设置各市场的数据源配置
4. 当配置更新时,选股系统应当验证配置的有效性
5. 当配置保存时,选股系统应当将配置持久化存储

74
docs/tasks.md Normal file
View File

@ -0,0 +1,74 @@
# 任务清单 - 基本面选股系统 MVP
本文档基于《设计文档》,将项目开发分解为一系列可执行的任务,以便于跟踪进度和分配工作。
## Phase 1: 项目基础设置与环境搭建 (P0)
此阶段的目标是搭建前后端开发环境,并完成数据库的初始化设置。
- **T1.1 [DevOps]**: 初始化Git仓库并建立`main`和`develop`分支策略。
- **T1.2 [Backend]**: 创建Python虚拟环境并初始化FastAPI项目结构 (`/backend`)。
- **T1.3 [Frontend]**: 使用Next.js和TypeScript初始化前端项目 (`/frontend`)。
- **T1.4 [DevOps/DB]**: 编写`docker-compose.yml`文件用于一键启动PostgreSQL数据库服务和后端API服务。
- **T1.5 [Backend/DB]**: 在后端项目中集成Alembic用于数据库版本迁移管理。
## Phase 2: 后端核心模型与配置 (P0)
此阶段专注于实现数据库模型和系统配置API为上层业务逻辑提供基础。
- **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。
- **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。
- **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。
- **T2.4 [Backend/API]**: 创建Pydantic Schema用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。
- **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。
- **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。
## Phase 3: 前端基础与配置页面 (P1)
此阶段完成前端项目的基本设置,并开发出第一个功能页面——系统配置管理。
- **T3.1 [Frontend]**: 集成并配置UI组件库 (Shadcn/UI)。
- **T3.2 [Frontend]**: 设置前端路由,创建`/`, `/report/[symbol]`, 和 `/config`等页面骨架。
- **T3.3 [Frontend]**: 引入状态管理库 (Zustand) 和数据请求库 (SWR/React-Query)。
- **T3.4 [Frontend/UI]**: 开发`ConfigPage`组件包含用于数据库、Gemini API和数据源配置的表单。
- **T3.5 [Frontend/API]**: 编写API客户端函数用于调用后端的`/api/config`系列接口。
- **T3.6 [Frontend/Feature]**: 将API客户端与`ConfigPage`组件集成,实现前端对系统配置的读取、更新和测试功能。
## Phase 4: 核心功能 - 报告生成与进度追踪 (P1)
此阶段是项目的核心,重点开发后端的报告生成流程和前端的实时进度展示。
- **T4.1 [Backend/Service]**: 实现`DataSourceManager`封装对Tushare和Yahoo Finance的数据获取逻辑。
- **T4.2 [Backend/Service]**: 实现`AIService`封装对Google Gemini API的调用逻辑包括Token使用统计。
- **T4.3 [Backend/Service]**: 实现`ProgressTracker`服务,提供`initialize`, `start_step`, `complete_step`, `get_progress`等方法,并与数据库交互。
- **T4.4 [Backend/Service]**: 定义`AnalysisModule`的基类/接口,并初步实现一到两个模块(如`FinancialDataModule`)作为示例。
- **T4.5 [Backend/Service]**: 实现核心的`ReportGenerator`服务,编排数据获取、各分析模块调用、进度更新的完整流程。
- **T4.6 [Backend/API]**: 实现`/api/reports`的`POST`端点,用于创建报告记录并通过`BackgroundTasks`启动异步生成任务。
- **T4.7 [Backend/API]**: 实现`/api/progress/stream/{report_id}`的SSE端点用于向前端实时推送进度更新。
- **T4.8 [Frontend/UI]**: 开发`ProgressTracker`组件,用于展示报告生成的步骤列表、实时状态、耗时和错误信息。
- **T4.9 [Frontend/Feature]**: 在报告页面集成`ProgressTracker`组件并实现连接到SSE端点的逻辑。
## Phase 5: 核心功能 - 报告查询与展示 (P2)
此阶段专注于将生成的报告数据在前端进行展示。
- **T5.1 [Backend/API]**: 实现`/api/reports/{report_id}`的`GET`端点,用于获取完整的报告及其所有分析模块。
- **T5.2 [Backend/API]**: 实现`/api/reports`的`GET`端点,用于获取报告列表,支持分页和状态筛选。
- **T5.3 [Frontend/UI]**: 开发`StockInputForm`组件,并放置在首页,用于提交股票代码和市场。
- **T5.4 [Frontend/Feature]**: 实现首页表单的提交逻辑,调用`/api/reports`接口并根据响应导航到报告页面。
- **T5.5 [Frontend/UI]**: 开发`ReportPage`,作为展示报告的容器页面。
- **T5.6 [Frontend/UI]**: 开发`ModuleNavigator`组件侧边栏或Tabs用于在不同分析模块间切换。
- **T5.7 [Frontend/UI]**: 开发`ModuleViewer`组件能够根据JSONB数据动态渲染文本、列表、表格等不同形式的内容。
- **T5.8 [Frontend/Feature]**: 在`ReportPage`中集成`ModuleNavigator`和`ModuleViewer`,实现从后端获取报告数据并完整展示的功能。
## Phase 6: 完善、测试与部署 (P3)
此阶段进行功能完善、端到端测试和部署准备。
- **T6.1 [Backend]**: 完善所有`AnalysisModule`的具体实现。
- **T6.2 [Backend/Test]**: 为核心服务和API端点编写单元测试和集成测试 (Pytest)。
- **T6.3 [Frontend/Test]**: 为关键组件和页面逻辑编写单元测试 (Jest/React Testing Library)。
- **T6.4 [General]**: 全面审查和增强错误处理逻辑,确保用户体验友好。
- **T6.5 [General]**: 进行端到端的手动测试,确保整个流程(从输入代码到查看报告)顺畅无误。
- **T6.6 [DevOps]**: 编写生产环境的Dockerfile和部署脚本。
- **T6.7 [Docs]**: 更新`README.md`,补充项目介绍、本地启动方法和部署指南。

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

22
frontend/components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

17
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8000/api/:path*",
},
{
source: "/health",
destination: "http://localhost:8000/health",
},
];
},
};
export default nextConfig;

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7272
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"recharts": "^3.3.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -0,0 +1,229 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
type Config = {
llm?: {
provider?: "gemini" | "openai";
gemini?: { api_key?: string; base_url?: string };
openai?: { api_key?: string; base_url?: string };
};
data_sources?: {
tushare?: { api_key?: string };
finnhub?: { api_key?: string };
jp_source?: { api_key?: string };
};
database?: { url?: string };
prompts?: { info?: string; finance?: string };
};
export default function ConfigPage() {
const [cfg, setCfg] = useState<Config | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [health, setHealth] = useState<string>("unknown");
// form inputs (敏感字段不回显,留空表示保持现有值)
const [provider, setProvider] = useState<"gemini" | "openai">("gemini");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const [geminiKey, setGeminiKey] = useState(""); // 留空则保留
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
const [openaiKey, setOpenaiKey] = useState(""); // 留空则保留
const [tushareKey, setTushareKey] = useState(""); // 留空则保留
const [finnhubKey, setFinnhubKey] = useState(""); // 留空则保留
const [jpKey, setJpKey] = useState(""); // 留空则保留
const [dbUrl, setDbUrl] = useState("");
const [promptInfo, setPromptInfo] = useState("");
const [promptFinance, setPromptFinance] = useState("");
async function loadConfig() {
try {
const res = await fetch("/api/config");
const data: Config = await res.json();
setCfg(data);
// 非敏感字段可回显
setProvider((data.llm?.provider as any) ?? "gemini");
setGeminiBaseUrl(data.llm?.gemini?.base_url ?? "");
setOpenaiBaseUrl(data.llm?.openai?.base_url ?? "");
setDbUrl(data.database?.url ?? "");
setPromptInfo(data.prompts?.info ?? "");
setPromptFinance(data.prompts?.finance ?? "");
} catch {
setMsg("加载配置失败");
}
}
async function saveConfig() {
if (!cfg) return;
setSaving(true);
setMsg(null);
try {
// 构造覆盖配置:敏感字段若为空则沿用现有值
const next: Config = {
llm: {
provider,
gemini: {
base_url: geminiBaseUrl,
api_key: geminiKey || cfg.llm?.gemini?.api_key || undefined,
},
openai: {
base_url: openaiBaseUrl,
api_key: openaiKey || cfg.llm?.openai?.api_key || undefined,
},
},
data_sources: {
tushare: { api_key: tushareKey || cfg.data_sources?.tushare?.api_key || undefined },
finnhub: { api_key: finnhubKey || cfg.data_sources?.finnhub?.api_key || undefined },
jp_source: { api_key: jpKey || cfg.data_sources?.jp_source?.api_key || undefined },
},
database: { url: dbUrl },
prompts: { info: promptInfo, finance: promptFinance },
};
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
const ok = await res.json();
if (ok?.status === "ok") {
setMsg("保存成功");
await loadConfig();
// 清空敏感输入(避免页面存储)
setGeminiKey("");
setOpenaiKey("");
setTushareKey("");
setFinnhubKey("");
setJpKey("");
} else {
setMsg("保存失败");
}
} catch {
setMsg("保存失败");
} finally {
setSaving(false);
}
}
async function testHealth() {
try {
const res = await fetch("/health");
const h = await res.json();
setHealth(h?.status ?? "unknown");
} catch {
setHealth("error");
}
}
useEffect(() => {
loadConfig();
testHealth();
}, []);
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
LLM
</p>
</header>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>GET /health</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2">
<Badge variant={health === "ok" ? "secondary" : "outline"}>{health}</Badge>
<Button variant="outline" onClick={testHealth}></Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>LLM </CardTitle>
<CardDescription>Gemini / OpenAI</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<label className="text-sm w-28">Provider</label>
<select
className="border rounded px-2 py-1 bg-background"
value={provider}
onChange={(e) => setProvider(e.target.value as any)}
>
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Gemini Base URL</label>
<Input placeholder="可留空" value={geminiBaseUrl} onChange={(e) => setGeminiBaseUrl(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">Gemini Key</label>
<Input type="password" placeholder="留空=保持现值" value={geminiKey} onChange={(e) => setGeminiKey(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">OpenAI Base URL</label>
<Input placeholder="可留空" value={openaiBaseUrl} onChange={(e) => setOpenaiBaseUrl(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">OpenAI Key</label>
<Input type="password" placeholder="留空=保持现值" value={openaiKey} onChange={(e) => setOpenaiKey(e.target.value)} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>TuShare / Finnhub / JP</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-center">
<label className="text-sm w-28">TuShare</label>
<Input type="password" placeholder="留空=保持现值" value={tushareKey} onChange={(e) => setTushareKey(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">Finnhub</label>
<Input type="password" placeholder="留空=保持现值" value={finnhubKey} onChange={(e) => setFinnhubKey(e.target.value)} />
</div>
<div className="flex gap-2 items-center">
<label className="text-sm w-28">JP Source</label>
<Input type="password" placeholder="留空=保持现值" value={jpKey} onChange={(e) => setJpKey(e.target.value)} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<label className="text-sm w-28">DB URL</label>
<Input placeholder="postgresql+asyncpg://..." value={dbUrl} onChange={(e) => setDbUrl(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Prompt Info</label>
<Input placeholder="模板info" value={promptInfo} onChange={(e) => setPromptInfo(e.target.value)} />
</div>
<div className="flex gap-2">
<label className="text-sm w-28">Prompt Finance</label>
<Input placeholder="模板finance" value={promptFinance} onChange={(e) => setPromptFinance(e.target.value)} />
</div>
<div className="flex gap-2">
<Button onClick={saveConfig} disabled={saving}>{saving ? "保存中…" : "保存配置"}</Button>
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function DocsPage() {
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">使</p>
</header>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="prose prose-sm dark:prose-invert">
<ol className="list-decimal pl-5 space-y-1">
<li> npm run dev </li>
<li> src/app </li>
<li>使 shadcn/ui </li>
</ol>
</CardContent>
</Card>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from "@/components/ui/navigation-menu";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Fundamental Analysis",
description: "上市公司基本面分析平台 · Next.js + shadcn/ui",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}>
<header className="border-b">
<div className="mx-auto w-full max-w-6xl px-4 py-3 flex items-center justify-between">
<div className="font-semibold">FA Platform</div>
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink href="/" className="px-3 py-2"></NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/reports" className="px-3 py-2"></NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/docs" className="px-3 py-2"></NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</header>
<main className="mx-auto w-full max-w-6xl px-4 py-8">
{children}
</main>
</body>
</html>
);
}

674
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,674 @@
"use client";
/**
* -
*
*
* -
* -
* -
* -
* -
*/
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StatusBar, useStatusBar } from "@/components/ui/status-bar";
import { createDefaultStepManager } from "@/lib/execution-step-manager";
import { RowSettingsPanel } from "@/components/ui/row-settings";
import { useRowConfig } from "@/hooks/use-row-config";
import { EnhancedTable } from "@/components/ui/enhanced-table";
import { Notification } from "@/components/ui/notification";
import {
normalizeTsCode,
flattenApiGroups,
enhanceErrorMessage,
isRetryableError,
formatFinancialValue,
getMetricUnit
} from "@/lib/financial-utils";
import type {
MarketType,
ChartType,
CompanyInfo,
CompanySuggestion,
RevenueDataPoint,
FinancialMetricConfig,
FinancialDataSeries,
ExecutionStep,
BatchFinancialDataResponse,
FinancialConfigResponse,
SearchApiResponse,
BatchDataRequest
} from "@/types";
import {
ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
export default function Home() {
// ============================================================================
// 基础状态管理
// ============================================================================
const [market, setMarket] = useState<MarketType>("cn");
const [query, setQuery] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [chartType, setChartType] = useState<ChartType>("bar");
// ============================================================================
// 数据状态管理
// ============================================================================
const [items, setItems] = useState<RevenueDataPoint[]>([]);
const [selected, setSelected] = useState<CompanyInfo | null>(null);
const [configItems, setConfigItems] = useState<FinancialMetricConfig[]>([]);
const [metricSeries, setMetricSeries] = useState<FinancialDataSeries>({});
const [selectedMetric, setSelectedMetric] = useState<string>("revenue");
const [selectedMetricName, setSelectedMetricName] = useState<string>("营业收入");
const [paramToGroup, setParamToGroup] = useState<Record<string, string>>({});
const [paramToApi, setParamToApi] = useState<Record<string, string>>({});
// ============================================================================
// 搜索相关状态
// ============================================================================
const [suggestions, setSuggestions] = useState<CompanySuggestion[]>([]);
const [typingTimer, setTypingTimer] = useState<NodeJS.Timeout | null>(null);
// ============================================================================
// 状态栏管理
// ============================================================================
const {
statusBarState,
showStatusBar,
showSuccess,
showError,
hideStatusBar
} = useStatusBar();
// ============================================================================
// 执行步骤管理
// ============================================================================
const [stepManager] = useState(() => {
return createDefaultStepManager({
onStepStart: (step: ExecutionStep, index: number, total: number) => {
showStatusBar(step, index, total);
},
onStepComplete: (_step: ExecutionStep, index: number, total: number) => {
// If there are more steps, update to next step
if (index < total - 1) {
// This will be handled by the next step start
} else {
// All steps completed
showSuccess();
}
},
onStepError: (_step: ExecutionStep, _index: number, _total: number, error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
},
onComplete: () => {
showSuccess();
},
onError: (error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
}
});
});
// ============================================================================
// 表格行配置管理
// ============================================================================
const [isRowSettingsPanelOpen, setIsRowSettingsPanelOpen] = useState(false);
// Row configuration management - memoize to prevent infinite re-renders
const rowIds = useMemo(() =>
configItems.map(item => item.tushareParam || '').filter(Boolean),
[configItems]
);
const {
rowConfigs,
customRows,
updateRowConfig,
saveStatus,
clearSaveStatus,
addCustomRow,
deleteCustomRow,
updateRowOrder
} = useRowConfig(selected?.ts_code || null, rowIds);
const rowDisplayTexts = useMemo(() => {
const texts = configItems.reduce((acc, item) => {
if (item.tushareParam) {
acc[item.tushareParam] = item.displayText;
}
return acc;
}, {} as Record<string, string>);
// 添加自定义行的显示文本
Object.entries(customRows).forEach(([rowId, customRow]) => {
texts[rowId] = customRow.displayText;
});
return texts;
}, [configItems, customRows]);
// ============================================================================
// 搜索建议功能
// ============================================================================
/**
*
* @param text -
*/
async function fetchSuggestions(text: string): Promise<void> {
if (market !== "cn") {
setSuggestions([]);
return;
}
const searchQuery = (text || "").trim();
if (!searchQuery) {
setSuggestions([]);
return;
}
try {
const response = await fetch(
`http://localhost:8000/api/search?query=${encodeURIComponent(searchQuery)}&limit=8`
);
const data: SearchApiResponse = await response.json();
const suggestions = Array.isArray(data?.items) ? data.items : [];
setSuggestions(suggestions);
} catch (error) {
console.warn('Failed to fetch suggestions:', error);
setSuggestions([]);
}
}
// ============================================================================
// 搜索处理功能
// ============================================================================
/**
*
*/
const retrySearch = async (): Promise<void> => {
if (stepManager.canRetry()) {
try {
await stepManager.retry();
} catch {
// 错误已经在stepManager中处理
}
} else {
// 如果不能重试,重新执行搜索
await handleSearch();
}
};
/**
*
*/
async function handleSearch(): Promise<void> {
// 重置状态
setError("");
setItems([]);
setMetricSeries({});
setConfigItems([]);
setSelectedMetric("revenue");
// 验证市场支持
if (market !== "cn") {
setError("目前仅支持 A 股查询,请选择\"中国\"市场。");
return;
}
// 获取并验证股票代码
const tsCode = selected?.ts_code || normalizeTsCode(query);
if (!tsCode) {
setError("请输入有效的 A 股股票代码(如 600519 / 000001 或带后缀 600519.SH。");
return;
}
setLoading(true);
try {
// 创建搜索执行步骤
const searchStep: ExecutionStep = {
id: 'fetch_financial_data',
name: '正在读取财务数据',
description: '从Tushare API获取公司财务指标数据',
execute: async () => {
await executeSearchStep(tsCode);
}
};
// 清空之前的步骤并添加新的搜索步骤
stepManager.clearSteps();
stepManager.addStep(searchStep);
// 执行搜索步骤
await stepManager.execute();
} catch (error) {
const errorMsg = enhanceErrorMessage(error);
setError(errorMsg);
// 错误处理已经在stepManager的回调中处理
} finally {
setLoading(false);
}
}
/**
*
* @param tsCode -
*/
async function executeSearchStep(tsCode: string): Promise<void> {
// 1) 获取配置tushare专用解析 api_groups -> 扁平 items
const configResponse = await fetch(`http://localhost:8000/api/financial-config`);
const configData: FinancialConfigResponse = await configResponse.json();
const groups = configData?.api_groups || {};
const { items, groupMap, apiMap } = flattenApiGroups(groups);
setConfigItems(items);
setParamToGroup(groupMap);
setParamToApi(apiMap);
// 2) 批量请求年度序列同API字段合并读取
const years = 10;
const metrics = Array.from(new Set(["revenue", ...items.map(i => i.tushareParam)]));
const batchRequest: BatchDataRequest = {
ts_code: tsCode,
years,
metrics
};
const batchResponse = await fetch(
`http://localhost:8000/api/financialdata/${encodeURIComponent(market)}/batch`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batchRequest),
}
);
const batchData: BatchFinancialDataResponse = await batchResponse.json();
const seriesObj = batchData?.series || {};
// 处理数据系列
const processedSeries: FinancialDataSeries = {};
for (const metric of metrics) {
const series = Array.isArray(seriesObj[metric]) ? seriesObj[metric] : [];
processedSeries[metric] = [...series].sort((a, b) => Number(a.year) - Number(b.year));
}
setMetricSeries(processedSeries);
// 3) 设置选中公司与默认图表序列(收入)
setSelected({
ts_code: batchData?.ts_code || tsCode,
name: batchData?.name
});
const revenueSeries = processedSeries["revenue"] || [];
setItems(revenueSeries.map(d => ({ year: d.year, revenue: d.value })));
const revenueName = items.find(i => i.tushareParam === "revenue")?.displayText || "营业收入";
setSelectedMetricName(revenueName);
if (revenueSeries.length === 0) {
throw new Error("未查询到数据,请确认代码或稍后重试。");
}
}
// ============================================================================
// 渲染组件
// ============================================================================
return (
<div className="space-y-8">
{/* StatusBar Component */}
<StatusBar
isVisible={statusBarState.isVisible}
currentStep={statusBarState.currentStep}
stepIndex={statusBarState.stepIndex}
totalSteps={statusBarState.totalSteps}
status={statusBarState.status}
errorMessage={statusBarState.errorMessage}
onDismiss={hideStatusBar}
onRetry={retrySearch}
retryable={statusBarState.retryable}
/>
{/* Configuration Save Status Notification */}
{saveStatus.status !== 'idle' && (
<Notification
message={saveStatus.message || ''}
type={saveStatus.status === 'success' ? 'success' : saveStatus.status === 'error' ? 'error' : 'info'}
isVisible={true}
onDismiss={clearSaveStatus}
position="bottom-right"
autoHide={saveStatus.status === 'success'}
autoHideDelay={2000}
/>
)}
{/* Row Settings Panel */}
<RowSettingsPanel
isOpen={isRowSettingsPanelOpen}
onClose={() => setIsRowSettingsPanelOpen(false)}
rowConfigs={rowConfigs}
rowDisplayTexts={rowDisplayTexts}
onConfigChange={updateRowConfig}
onRowOrderChange={updateRowOrder}
onDeleteCustomRow={deleteCustomRow}
enableRowReordering={true}
/>
<section className="space-y-3">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
使 Next.js + shadcn/ui
</p>
<div className="flex gap-2 max-w-xl relative">
<Select value={market} onValueChange={(v) => setMarket(v as MarketType)}>
<SelectTrigger className="w-28 sm:w-40" aria-label="选择市场">
<SelectValue placeholder="选择市场" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cn"></SelectItem>
<SelectItem value="us"></SelectItem>
<SelectItem value="hk"></SelectItem>
<SelectItem value="jp"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
<Input
placeholder="输入股票代码或公司名,例如 600519 / 000001 / 贵州茅台"
value={query}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
setSelected(null);
if (typingTimer) clearTimeout(typingTimer);
const t = setTimeout(() => fetchSuggestions(v), 250);
setTypingTimer(t);
}}
/>
{/* 下拉建议 */}
{suggestions.length > 0 && (
<div className="absolute top-12 left-0 right-0 z-10 bg-white border rounded shadow">
{suggestions.map((s, i) => (
<div
key={s.ts_code + i}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between"
onClick={() => {
setQuery(`${s.ts_code} ${s.name}`);
setSelected({ ts_code: s.ts_code, name: s.name });
setSuggestions([]);
}}
>
<span>{s.name}</span>
<span className="text-muted-foreground">{s.ts_code}</span>
</div>
))}
</div>
)}
<Button onClick={handleSearch} disabled={loading}>
{loading ? "查询中..." : "搜索"}
</Button>
</div>
{error && <Badge variant="secondary">{error}</Badge>}
</section>
<section>
{items.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>10A股</CardTitle>
<CardDescription> Tushare{selected && selected.name ? `${selected.name} (${selected.ts_code})` : selected?.ts_code}</CardDescription>
</div>
<div className="mt-1">
<Select value={chartType} onValueChange={(v) => setChartType(v as ChartType)}>
<SelectTrigger className="w-40" aria-label="选择图表类型">
<SelectValue placeholder="选择图表类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"></SelectItem>
<SelectItem value="line">线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 使用 Recharts 渲染图表(动态单位) */}
{(() => {
// 获取当前选中指标的信息
const currentMetricInfo = configItems.find(ci => (ci.tushareParam || "") === selectedMetric);
const metricGroup = currentMetricInfo?.group;
const metricApi = currentMetricInfo?.api;
const metricUnit = getMetricUnit(metricGroup, metricApi, selectedMetric);
// 构建完整的图例名称
const legendName = `${selectedMetricName}${metricUnit}`;
// 根据指标类型确定数据缩放和单位
const shouldScaleToYi = (
metricGroup === "income" ||
metricGroup === "balancesheet" ||
metricGroup === "cashflow" ||
selectedMetric === "total_mv"
);
const chartData = items.map((d) => {
let scaledValue = typeof d.revenue === "number" ? d.revenue : 0;
if (shouldScaleToYi) {
// 对于财务报表数据,转换为亿元
if (selectedMetric === "total_mv") {
// 市值从万元转为亿元
scaledValue = scaledValue / 1e4;
} else {
// 其他财务数据从元转为亿元
scaledValue = scaledValue / 1e8;
}
}
return {
year: d.year,
metricValue: scaledValue,
};
});
return (
<div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%">
{chartType === "bar" ? (
<BarChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Bar dataKey="metricValue" name={legendName} fill="#4f46e5" />
</BarChart>
) : (
<LineChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Line type="monotone" dataKey="metricValue" name={legendName} stroke="#4f46e5" dot />
</LineChart>
)}
</ResponsiveContainer>
</div>
);
})()}
{/* 增强数据表格 */}
{(() => {
const series = metricSeries;
const allYears = Object.values(series)
.flat()
.map(d => d.year);
if (allYears.length === 0) return null;
const yearsDesc = Array.from(new Set(allYears)).sort((a, b) => Number(b) - Number(a));
const columns = yearsDesc;
function valueOf(m: string, year: string): number | null | undefined {
const s = series[m] || [];
const f = s.find(d => d.year === year);
return f ? f.value : undefined;
}
function fmtCell(m: string, y: string): string {
const v = valueOf(m, y);
const group = paramToGroup[m] || "";
const api = paramToApi[m] || "";
return formatFinancialValue(v, group, api, m);
}
// 行点击切换图表数据源
function onRowClick(m: string) {
setSelectedMetric(m);
// 指标中文名传给图表
const rowInfo = configItems.find(ci => (ci.tushareParam || "") === m);
setSelectedMetricName(rowInfo?.displayText || m);
const s = series[m] || [];
setItems(s.map(d => ({ year: d.year, revenue: d.value })));
}
// 准备表格数据
const baseTableData = configItems.map((row, idx) => {
const m = (row.tushareParam || "").trim();
const values: Record<string, string> = {};
if (m) {
columns.forEach(year => {
values[year] = fmtCell(m, year);
});
} else {
columns.forEach(year => {
values[year] = "-";
});
}
return {
id: m || `row_${idx}`,
displayText: row.displayText + getMetricUnit(row.group, row.api, row.tushareParam),
values,
group: row.group,
api: row.api,
tushareParam: row.tushareParam,
isCustomRow: false
};
});
// 添加自定义行数据(仅分隔线)
const customRowData = Object.entries(customRows)
.filter(([, customRow]) => customRow.customRowType === 'separator')
.map(([rowId, customRow]) => {
const values: Record<string, string> = {};
columns.forEach(year => {
values[year] = "-"; // 分隔线不显示数据
});
return {
id: rowId,
displayText: customRow.displayText,
values,
isCustomRow: true,
customRowType: customRow.customRowType
};
});
// 合并基础数据和自定义行数据
const tableData = [...baseTableData, ...customRowData];
return (
<EnhancedTable
data={tableData}
columns={columns}
rowConfigs={rowConfigs}
selectedRowId={selectedMetric}
onRowClick={onRowClick}
onRowConfigChange={updateRowConfig}
onOpenSettings={() => setIsRowSettingsPanelOpen(true)}
onAddCustomRow={addCustomRow}
onDeleteCustomRow={deleteCustomRow}
onRowOrderChange={updateRowOrder}
enableAnimations={true}
animationDuration={300}
enableVirtualization={configItems.length > 50}
maxVisibleRows={50}
enableRowDragging={true}
/>
);
})()}
</CardContent>
</Card>
)}
</section>
</div>
);
}

View File

@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
type ReportItem = {
report_id: string;
created_at?: number;
score?: number;
status?: string;
};
export default function QueryPage() {
const [market, setMarket] = useState<"cn" | "us" | "jp">("cn");
const [orgId, setOrgId] = useState("AAPL");
const [loading, setLoading] = useState(false);
const [reports, setReports] = useState<ReportItem[]>([]);
const [msg, setMsg] = useState<string | null>(null);
async function loadReports() {
if (!market || !orgId) return;
setLoading(true);
try {
const res = await fetch(`/api/orgs/${market}/${orgId}/reports`);
const data = await res.json();
setReports(data.reports ?? []);
} catch (e) {
setMsg("加载失败");
} finally {
setLoading(false);
}
}
async function triggerGenerate() {
if (!market || !orgId) return;
setMsg("已触发生成任务…");
try {
const res = await fetch(`/api/orgs/${market}/${orgId}/reports/generate`, {
method: "POST",
});
const data = await res.json();
if (data.queued) {
setMsg("生成任务已入队,稍后自动出现在列表中");
// 简单轮询刷新
setTimeout(loadReports, 1500);
} else {
setMsg("触发失败");
}
} catch {
setMsg("触发失败");
}
}
useEffect(() => {
loadReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">ID与市场</p>
</header>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>ID us:AAPL / cn:600519</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<select
className="border rounded px-2 py-1 bg-background"
value={market}
onChange={(e) => setMarket(e.target.value as "cn" | "us" | "jp")}
>
<option value="cn">cn</option>
<option value="us">us</option>
<option value="jp">jp</option>
</select>
<Input
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
placeholder="输入企业ID如 AAPL / 600519"
/>
<Button onClick={loadReports} disabled={loading}></Button>
<Button onClick={triggerGenerate} variant="secondary"></Button>
</div>
{msg && <p className="text-xs text-muted-foreground">{msg}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{loading && <p></p>}
{!loading && reports.length === 0 && <p></p>}
{reports.map((r) => (
<div key={r.report_id} className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-mono text-sm">#{r.report_id}</span>
<Badge variant={r.status === "done" ? "secondary" : "outline"}>{r.status ?? "unknown"}</Badge>
</div>
<div className="text-sm text-muted-foreground">
{r.created_at ? new Date(r.created_at * 1000).toLocaleString() : "-"}
</div>
<div className="text-sm">{r.score ?? "-"}</div>
<div className="flex gap-2">
<a
className="text-xs underline"
href={`/api/reports/${r.report_id}?market=${market}&org_id=${orgId}`}
target="_blank"
rel="noreferrer"
>
JSON
</a>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,164 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
// ============================================================================
// 类型定义
// ============================================================================
export interface AddRowMenuProps {
/** 添加新行回调 */
onAddRow: (rowType: 'separator', customText?: string) => void;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
// ============================================================================
// 添加行菜单组件
// ============================================================================
export function AddRowMenu({ onAddRow, disabled = false, className }: AddRowMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [customText, setCustomText] = useState("");
// 移除selectedType因为只支持分隔线
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
setCustomText("");
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
// 自动聚焦到输入框
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleAddRow = () => {
const text = customText.trim() || "分组标题";
onAddRow('separator', text);
setIsOpen(false);
setCustomText("");
};
const getTypeIcon = () => {
return (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
);
};
if (!isOpen) {
return (
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(true)}
disabled={disabled}
className={cn("flex items-center gap-2", className)}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
线
</Button>
);
}
return (
<div ref={menuRef} className={cn("relative", className)}>
<div className="absolute top-0 left-0 z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-80 animate-in fade-in-0 zoom-in-95 duration-200">
{/* 标题 */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-900"></h3>
<button
onClick={() => {
setIsOpen(false);
setCustomText("");
}}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 hover:bg-gray-100 rounded"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
{/* 行类型选择 - 只显示分隔线 */}
<div className="space-y-2 mb-4">
<label className="text-xs font-medium text-gray-700">线</label>
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
{getTypeIcon()}
<span>线</span>
</div>
<p className="text-xs text-gray-500 mt-1">
线
</p>
</div>
</div>
{/* 自定义文本输入 */}
<div className="space-y-2 mb-4">
<label className="text-xs font-medium text-gray-700">
线
<span className="text-gray-500 ml-1">()</span>
</label>
<input
ref={inputRef}
type="text"
value={customText}
onChange={(e) => setCustomText(e.target.value)}
placeholder="分组标题"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddRow();
} else if (e.key === 'Escape') {
setIsOpen(false);
setCustomText("");
}
}}
/>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setIsOpen(false);
setCustomText("");
}}
>
</Button>
<Button
size="sm"
onClick={handleAddRow}
>
线
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,289 @@
"use client";
import React, { useState, useRef } from "react";
import { cn } from "@/lib/utils";
import { TableRow, TableCell } from "@/components/ui/table";
import type { TableRowData } from "./enhanced-table";
// ============================================================================
// 类型定义
// ============================================================================
export interface DraggableRowProps {
/** 行数据 */
rowData: TableRowData;
/** 列数组 */
columns: string[];
/** 行索引 */
index: number;
/** 是否可见 */
isVisible: boolean;
/** 是否选中 */
isSelected: boolean;
/** 是否启用拖拽 */
isDraggable: boolean;
/** 是否正在拖拽 */
isDragging: boolean;
/** 是否为拖拽目标 */
isDragOver: boolean;
/** 动画持续时间 */
animationDuration: number;
/** 点击回调 */
onClick: () => void;
/** 拖拽开始回调 */
onDragStart: (index: number) => void;
/** 拖拽结束回调 */
onDragEnd: () => void;
/** 拖拽进入回调 */
onDragEnter: (index: number) => void;
/** 拖拽离开回调 */
onDragLeave: () => void;
/** 拖拽悬停回调 */
onDragOver: (e: React.DragEvent) => void;
/** 放置回调 */
onDrop: (e: React.DragEvent) => void;
/** 删除自定义行回调 */
onDeleteCustomRow?: (rowId: string) => void;
}
// ============================================================================
// 拖拽行组件
// ============================================================================
export const DraggableRow = React.memo<DraggableRowProps>(({
rowData,
columns,
index,
isVisible,
isSelected,
isDraggable,
isDragging,
isDragOver,
animationDuration,
onClick,
onDragStart,
onDragEnd,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onDeleteCustomRow
}) => {
const [isHovered, setIsHovered] = useState(false);
const dragHandleRef = useRef<HTMLDivElement>(null);
const handleDragStart = (e: React.DragEvent) => {
if (!isDraggable) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', rowData.id);
onDragStart(index);
};
const handleClick = (e: React.MouseEvent) => {
// 如果点击的是拖拽手柄或删除按钮,不触发行点击
if (
dragHandleRef.current?.contains(e.target as Node) ||
(e.target as HTMLElement).closest('.delete-button')
) {
return;
}
// 如果是自定义行(特别是分隔线),不触发行点击
if (rowData.isCustomRow) {
return;
}
onClick();
};
const handleDeleteCustomRow = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteCustomRow && rowData.isCustomRow) {
onDeleteCustomRow(rowData.id);
}
};
// 渲染自定义行内容
const renderCustomRowContent = () => {
if (!rowData.isCustomRow) return null;
switch (rowData.customRowType) {
case 'empty':
return (
<>
<TableCell className="font-medium text-gray-500 italic">
<div className="flex items-center gap-2">
{isDraggable && (
<div
ref={dragHandleRef}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded"
title="拖拽排序"
>
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</div>
)}
<span>{rowData.displayText}</span>
{onDeleteCustomRow && (
<button
onClick={handleDeleteCustomRow}
className="delete-button ml-auto p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"
title="删除此行"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</TableCell>
{columns.map((column) => (
<TableCell key={column} className="text-right text-gray-400 italic">
{column}
</TableCell>
))}
</>
);
case 'separator':
return (
<TableCell colSpan={columns.length + 1} className="py-2">
<div className="flex items-center gap-2">
{isDraggable && (
<div
ref={dragHandleRef}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded"
title="拖拽排序"
>
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</div>
)}
<div className="flex-1 border-t border-gray-300"></div>
<span className="text-sm text-gray-500 px-2">{rowData.displayText}</span>
<div className="flex-1 border-t border-gray-300"></div>
{onDeleteCustomRow && (
<button
onClick={handleDeleteCustomRow}
className="delete-button p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"
title="删除此行"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</TableCell>
);
case 'note':
return (
<>
<TableCell className="font-medium text-blue-600">
<div className="flex items-center gap-2">
{isDraggable && (
<div
ref={dragHandleRef}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded"
title="拖拽排序"
>
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</div>
)}
<svg className="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span>{rowData.displayText}</span>
{onDeleteCustomRow && (
<button
onClick={handleDeleteCustomRow}
className="delete-button ml-auto p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"
title="删除此行"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</TableCell>
{columns.map((column) => (
<TableCell key={column} className="text-right text-gray-400">
-
</TableCell>
))}
</>
);
default:
return null;
}
};
// 渲染普通数据行内容
const renderDataRowContent = () => (
<>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isDraggable && (
<div
ref={dragHandleRef}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded opacity-0 group-hover:opacity-100 transition-opacity"
title="拖拽排序"
>
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</div>
)}
<span>{rowData.displayText}</span>
</div>
</TableCell>
{columns.map((column) => (
<TableCell key={column} className="text-right">
{rowData.values[column] ?? "-"}
</TableCell>
))}
</>
);
return (
<TableRow
draggable={isDraggable}
isVisible={isVisible}
animationDuration={animationDuration}
className={cn(
"group transition-all duration-200",
isSelected && "bg-indigo-50 border-indigo-200",
isDragging && "opacity-50 scale-95",
isDragOver && "bg-blue-50 border-blue-300 border-t-2",
isHovered && !isDragging && "bg-muted/70",
rowData.isCustomRow && "bg-gray-50/50",
!rowData.isCustomRow && "cursor-pointer hover:bg-muted/70",
rowData.isCustomRow && "cursor-default"
)}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
onDragEnter={() => onDragEnter(index)}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{rowData.isCustomRow ? renderCustomRowContent() : renderDataRowContent()}
</TableRow>
);
});
DraggableRow.displayName = "DraggableRow";

View File

@ -0,0 +1,527 @@
"use client";
/**
*
*
*
* - /
* -
* -
* -
* -
*
* @author Financial Analysis Platform Team
* @version 1.0.0
*/
import React, { useMemo, useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { RowSettingsButton } from "@/components/ui/row-settings";
import { TableRowConfig } from "@/components/ui/row-settings";
import { DraggableRow } from "./draggable-row";
import { AddRowMenu } from "./add-row-menu";
// ============================================================================
// 类型定义
// ============================================================================
/**
*
*/
export interface TableRowData {
/** 行唯一标识符 */
id: string;
/** 显示文本 */
displayText: string;
/** 各年份/列的值 */
values: Record<string, string | number | null>;
/** 指标分组 */
group?: string;
/** API接口名 */
api?: string;
/** Tushare参数名 */
tushareParam?: string;
/** 是否为用户添加的自定义行 */
isCustomRow?: boolean;
/** 自定义行类型 */
customRowType?: 'empty' | 'separator' | 'note';
}
/**
*
*/
export interface EnhancedTableProps {
/** 表格数据 */
data: TableRowData[];
/** 列标题数组 */
columns: string[];
/** 行配置对象 */
rowConfigs: Record<string, TableRowConfig>;
/** 当前选中的行ID */
selectedRowId?: string;
/** 行点击回调 */
onRowClick?: (rowId: string) => void;
/** 配置变更回调 */
onRowConfigChange: (configs: Record<string, TableRowConfig>) => void;
/** 打开设置面板回调 */
onOpenSettings: () => void;
/** 添加新行回调 */
onAddCustomRow?: (rowType: 'separator') => void;
/** 删除自定义行回调 */
onDeleteCustomRow?: (rowId: string) => void;
/** 行顺序变更回调 */
onRowOrderChange?: (newOrder: string[]) => void;
/** 自定义CSS类名 */
className?: string;
/** 是否启用动画效果 */
enableAnimations?: boolean;
/** 动画持续时间(毫秒) */
animationDuration?: number;
/** 是否启用虚拟化渲染 */
enableVirtualization?: boolean;
/** 虚拟化最大可见行数 */
maxVisibleRows?: number;
/** 是否启用行拖拽排序 */
enableRowDragging?: boolean;
}
// ============================================================================
// 子组件类型定义
// ============================================================================
/**
*
*/
interface VirtualizedRowProps {
/** 行数据 */
rowData: TableRowData;
/** 列数组 */
columns: string[];
/** 是否可见 */
isVisible: boolean;
/** 是否选中 */
isSelected: boolean;
/** 点击回调 */
onClick: () => void;
/** 动画持续时间 */
animationDuration: number;
}
/**
*
*
* 使React.memo优化性能
*/
const VirtualizedRow = React.memo<VirtualizedRowProps>(({
rowData,
columns,
isVisible,
isSelected,
onClick,
animationDuration
}) => {
return (
<TableRow
isVisible={isVisible}
animationDuration={animationDuration}
className={cn(
"cursor-pointer transition-all duration-200",
isSelected && "bg-indigo-50 border-indigo-200",
"hover:bg-muted/70"
)}
onClick={onClick}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<span>{rowData.displayText}</span>
</div>
</TableCell>
{columns.map((column) => (
<TableCell key={column} className="text-right">
{rowData.values[column] ?? "-"}
</TableCell>
))}
</TableRow>
);
});
VirtualizedRow.displayName = "VirtualizedRow";
// ============================================================================
// 主组件
// ============================================================================
/**
*
*
*
*
* @param props -
* @returns JSX元素
*/
export function EnhancedTable({
data,
columns,
rowConfigs,
selectedRowId,
onRowClick,
onRowConfigChange,
onOpenSettings,
onAddCustomRow,
onDeleteCustomRow,
onRowOrderChange,
className,
enableAnimations = true,
animationDuration = 300,
enableVirtualization = false,
maxVisibleRows = 50,
enableRowDragging = true
}: EnhancedTableProps) {
const [isConfigPreviewMode, setIsConfigPreviewMode] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 过滤和排序可见行
const visibleRows = useMemo(() => {
try {
const filtered = data.filter(row => {
const config = rowConfigs[row.id];
return config?.isVisible !== false;
});
// 确保至少有一行可见
if (filtered.length === 0 && data.length > 0) {
console.warn('No visible rows found, showing first row as fallback');
return [data[0]];
}
return filtered.sort((a, b) => {
const aOrder = rowConfigs[a.id]?.displayOrder ?? 0;
const bOrder = rowConfigs[b.id]?.displayOrder ?? 0;
return aOrder - bOrder;
});
} catch (error) {
console.error('Error filtering visible rows:', error);
// 发生错误时返回所有数据作为后备
return data;
}
}, [data, rowConfigs]);
// 虚拟化处理(仅在启用时)
const displayRows = useMemo(() => {
if (!enableVirtualization || visibleRows.length <= maxVisibleRows) {
return visibleRows;
}
// 简单的虚拟化只显示前N行
return visibleRows.slice(0, maxVisibleRows);
}, [visibleRows, enableVirtualization, maxVisibleRows]);
// 行点击处理
const handleRowClick = useCallback((rowId: string) => {
if (onRowClick && !isConfigPreviewMode) {
onRowClick(rowId);
}
}, [onRowClick, isConfigPreviewMode]);
// 配置预览切换
const toggleConfigPreview = useCallback(() => {
setIsConfigPreviewMode(prev => !prev);
}, []);
// 快速显示/隐藏行
const quickToggleRow = useCallback((rowId: string, event: React.MouseEvent) => {
event.stopPropagation();
try {
const currentConfig = rowConfigs[rowId] || { rowId, isVisible: true, displayOrder: 0 };
// 如果要隐藏行,检查是否会导致所有行都被隐藏
if (currentConfig.isVisible) {
const visibleCount = Object.values(rowConfigs).filter(config => config.isVisible).length;
if (visibleCount <= 1) {
console.warn('Cannot hide the last visible row');
return;
}
}
const newConfigs = {
...rowConfigs,
[rowId]: {
...currentConfig,
rowId,
isVisible: !currentConfig.isVisible
}
};
onRowConfigChange(newConfigs);
} catch (error) {
console.error('Error toggling row visibility:', error);
}
}, [rowConfigs, onRowConfigChange]);
// 拖拽排序处理
const handleDragStart = useCallback((index: number) => {
if (!enableRowDragging) return;
setDraggedIndex(index);
}, [enableRowDragging]);
const handleDragEnd = useCallback(() => {
setDraggedIndex(null);
setDragOverIndex(null);
}, []);
const handleDragEnter = useCallback((index: number) => {
setDragOverIndex(index);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverIndex(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex || !enableRowDragging) {
return;
}
try {
// 重新排序可见行
const newVisibleRows = [...displayRows];
const [draggedRow] = newVisibleRows.splice(draggedIndex, 1);
newVisibleRows.splice(dropIndex, 0, draggedRow);
// 更新配置中的显示顺序
const newConfigs = { ...rowConfigs };
newVisibleRows.forEach((row, index) => {
if (newConfigs[row.id]) {
newConfigs[row.id] = { ...newConfigs[row.id], displayOrder: index };
}
});
onRowConfigChange(newConfigs);
if (onRowOrderChange) {
const newOrder = newVisibleRows.map(row => row.id);
onRowOrderChange(newOrder);
}
} catch (error) {
console.error('Error reordering rows:', error);
}
}, [draggedIndex, displayRows, rowConfigs, onRowConfigChange, onRowOrderChange, enableRowDragging]);
// 添加自定义行处理
const handleAddCustomRow = useCallback((rowType: 'separator') => {
if (onAddCustomRow) {
onAddCustomRow(rowType);
}
}, [onAddCustomRow]);
// 删除自定义行处理
const handleDeleteCustomRow = useCallback((rowId: string) => {
if (onDeleteCustomRow) {
onDeleteCustomRow(rowId);
}
}, [onDeleteCustomRow]);
// 性能统计
const stats = useMemo(() => {
const total = data.length;
const visible = visibleRows.length;
const hidden = total - visible;
return { total, visible, hidden };
}, [data.length, visibleRows.length]);
return (
<div className={cn("w-full space-y-2", className)}>
{/* 表格控制栏 */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>
{stats.visible} / {stats.total}
{stats.hidden > 0 && (
<span className="text-orange-600 ml-1">
({stats.hidden} )
</span>
)}
</span>
{enableVirtualization && displayRows.length < visibleRows.length && (
<span className="text-blue-600">
{displayRows.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
{onAddCustomRow && (
<AddRowMenu
onAddRow={handleAddCustomRow}
disabled={isConfigPreviewMode}
/>
)}
{isConfigPreviewMode && (
<span className="text-blue-600 text-xs"></span>
)}
<button
onClick={toggleConfigPreview}
className={cn(
"px-2 py-1 text-xs rounded transition-colors",
isConfigPreviewMode
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{isConfigPreviewMode ? "退出预览" : "配置预览"}
</button>
</div>
</div>
{/* 表格 */}
<div className="w-full overflow-x-auto">
{displayRows.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="text-sm"></div>
<div className="text-xs mt-1"></div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-56">
<div className="flex items-center justify-between">
<span></span>
<div className="flex items-center gap-1">
{enableRowDragging && (
<span className="text-xs text-gray-400" title="支持拖拽排序">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</span>
)}
<RowSettingsButton onClick={onOpenSettings} />
</div>
</div>
</TableHead>
{columns.map((column) => (
<TableHead key={column} className="text-right">
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map((row, index) => {
try {
const isSelected = selectedRowId === row.id;
const isVisible = rowConfigs[row.id]?.isVisible !== false;
const canHide = Object.values(rowConfigs).filter(config => config.isVisible).length > 1;
return (
<React.Fragment key={row.id}>
{isConfigPreviewMode ? (
// 配置预览模式:显示快速切换按钮
<TableRow
isVisible={isVisible}
animationDuration={enableAnimations ? animationDuration : 0}
className={cn(
"transition-all duration-200",
isSelected && "bg-indigo-50 border-indigo-200",
!isVisible && "opacity-50 bg-gray-50"
)}
>
<TableCell className="font-medium">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span>{row.displayText}</span>
{(row.group === "income" ||
row.group === "balancesheet" ||
row.group === "cashflow" ||
row.tushareParam === "total_mv") && (
<span className="text-xs text-muted-foreground">亿</span>
)}
</div>
<button
onClick={(e) => quickToggleRow(row.id, e)}
disabled={isVisible && !canHide}
className={cn(
"px-2 py-1 text-xs rounded transition-colors",
isVisible && canHide && "bg-green-100 text-green-700 hover:bg-green-200",
!isVisible && "bg-red-100 text-red-700 hover:bg-red-200",
isVisible && !canHide && "bg-gray-100 text-gray-400 cursor-not-allowed"
)}
title={isVisible && !canHide ? "至少需要保留一行可见" : undefined}
>
{isVisible ? "隐藏" : "显示"}
</button>
</div>
</TableCell>
{columns.map((column) => (
<TableCell key={column} className="text-right">
{row.values[column] ?? "-"}
</TableCell>
))}
</TableRow>
) : (
// 正常模式:可拖拽排序的行
<DraggableRow
rowData={row}
columns={columns}
index={index}
isVisible={isVisible}
isSelected={isSelected}
isDraggable={enableRowDragging}
isDragging={draggedIndex === index}
isDragOver={dragOverIndex === index}
animationDuration={enableAnimations ? animationDuration : 0}
onClick={() => handleRowClick(row.id)}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
onDeleteCustomRow={handleDeleteCustomRow}
/>
)}
</React.Fragment>
);
} catch (error) {
console.error('Error rendering table row:', error);
return (
<TableRow key={`error-${row.id}`}>
<TableCell colSpan={columns.length + 1} className="text-center text-red-500 text-sm">
: {row.displayText}
</TableCell>
</TableRow>
);
}
})}
</TableBody>
</Table>
)}
</div>
{/* 性能提示 */}
{data.length > 100 && (
<div className="text-xs text-muted-foreground text-center py-2">
<span>
{enableVirtualization && ` (虚拟化已启用)`}
</span>
</div>
)}
</div>
);
}
// Types are already exported with their interface declarations

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
export interface NotificationProps {
message: string;
type: 'success' | 'error' | 'info' | 'warning';
isVisible: boolean;
onDismiss?: () => void;
autoHide?: boolean;
autoHideDelay?: number;
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
}
export function Notification({
message,
type,
isVisible,
onDismiss,
autoHide = true,
autoHideDelay = 3000,
position = 'top-right'
}: NotificationProps) {
const [shouldRender, setShouldRender] = useState(isVisible);
// 处理显示/隐藏动画
useEffect(() => {
if (isVisible) {
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isVisible]);
// 自动隐藏
useEffect(() => {
if (isVisible && autoHide && onDismiss) {
const timer = setTimeout(() => {
onDismiss();
}, autoHideDelay);
return () => clearTimeout(timer);
}
}, [isVisible, autoHide, autoHideDelay, onDismiss]);
if (!shouldRender) return null;
const positionClasses = {
'top-right': 'top-4 right-4',
'top-left': 'top-4 left-4',
'bottom-right': 'bottom-4 right-4',
'bottom-left': 'bottom-4 left-4',
'top-center': 'top-4 left-1/2 transform -translate-x-1/2',
'bottom-center': 'bottom-4 left-1/2 transform -translate-x-1/2'
};
const typeStyles = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800'
};
const iconMap = {
success: (
<svg className="h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
),
error: (
<svg className="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
),
warning: (
<svg className="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
),
info: (
<svg className="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
)
};
return (
<div
className={cn(
"fixed z-50 transition-all duration-300 ease-in-out",
positionClasses[position],
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
)}
>
<div className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border max-w-sm",
typeStyles[type]
)}>
<div className="flex-shrink-0">
{iconMap[type]}
</div>
<div className="flex-1 text-sm font-medium">
{message}
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="flex-shrink-0 rounded-full p-1 hover:bg-black hover:bg-opacity-10 transition-colors"
>
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,403 @@
"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { SortableRowItem } from "./sortable-row-item";
// 表格行配置接口
export interface TableRowConfig {
rowId: string;
isVisible: boolean;
displayOrder?: number;
isCustomRow?: boolean;
customRowType?: 'empty' | 'separator' | 'note';
}
// 行设置组件属性
export interface RowSettingsProps {
rowId: string;
displayText: string;
isVisible: boolean;
onVisibilityChange: (rowId: string, visible: boolean) => void;
}
// 配置面板属性
export interface RowSettingsPanelProps {
isOpen: boolean;
onClose: () => void;
rowConfigs: Record<string, TableRowConfig>;
rowDisplayTexts: Record<string, string>;
onConfigChange: (configs: Record<string, TableRowConfig>) => void;
onRowOrderChange?: (newOrder: string[]) => void;
onDeleteCustomRow?: (rowId: string) => void;
enableRowReordering?: boolean;
}
// 单个行设置组件
export function RowSettings({
rowId,
displayText,
isVisible,
onVisibilityChange
}: RowSettingsProps) {
return (
<div className={cn(
"flex items-center justify-between py-3 px-4 hover:bg-gray-50 rounded-lg transition-colors border",
isVisible ? "border-green-200 bg-green-50/30" : "border-gray-200 bg-gray-50/30"
)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
"w-2 h-2 rounded-full flex-shrink-0",
isVisible ? "bg-green-500" : "bg-gray-400"
)} />
<span className={cn(
"text-sm font-medium truncate",
isVisible ? "text-gray-900" : "text-gray-500"
)}>
{displayText}
</span>
</div>
<label className="flex items-center cursor-pointer ml-3">
<input
type="checkbox"
checked={isVisible}
onChange={(e) => onVisibilityChange(rowId, e.target.checked)}
className="sr-only"
/>
<div className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2",
isVisible ? "bg-blue-600" : "bg-gray-300"
)}>
<span className={cn(
"inline-block h-5 w-5 transform rounded-full bg-white shadow-lg transition-transform duration-200 ease-in-out",
isVisible ? "translate-x-5" : "translate-x-0.5"
)} />
</div>
</label>
</div>
);
}
// 配置面板组件
export function RowSettingsPanel({
isOpen,
onClose,
rowConfigs,
rowDisplayTexts,
onConfigChange,
onRowOrderChange,
onDeleteCustomRow,
enableRowReordering = true
}: RowSettingsPanelProps) {
const [localConfigs, setLocalConfigs] = useState(() => rowConfigs);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Sync local configs when panel opens
useEffect(() => {
if (isOpen) {
setLocalConfigs(rowConfigs);
}
}, [isOpen, rowConfigs]);
const handleVisibilityChange = (rowId: string, visible: boolean) => {
try {
// 如果要隐藏行,检查是否会导致所有行都被隐藏
if (!visible) {
const currentVisibleCount = Object.values(localConfigs).filter(config => config.isVisible).length;
if (currentVisibleCount <= 1) {
// 防止隐藏最后一个可见行
console.warn('Cannot hide the last visible row');
return;
}
}
const newConfigs = {
...localConfigs,
[rowId]: {
...localConfigs[rowId],
rowId,
isVisible: visible
}
};
setLocalConfigs(newConfigs);
onConfigChange(newConfigs);
} catch (error) {
console.error('Failed to change row visibility:', error);
}
};
const handleShowAll = () => {
try {
const newConfigs = { ...localConfigs };
Object.keys(newConfigs).forEach(rowId => {
newConfigs[rowId] = { ...newConfigs[rowId], isVisible: true };
});
setLocalConfigs(newConfigs);
onConfigChange(newConfigs);
} catch (error) {
console.error('Failed to show all rows:', error);
}
};
const handleHideAll = () => {
try {
const newConfigs = { ...localConfigs };
const configKeys = Object.keys(newConfigs);
// 防止隐藏所有行(至少保留一行可见)
if (configKeys.length <= 1) {
return; // 如果只有一行或没有行,不执行隐藏全部
}
configKeys.forEach(rowId => {
newConfigs[rowId] = { ...newConfigs[rowId], isVisible: false };
});
setLocalConfigs(newConfigs);
onConfigChange(newConfigs);
} catch (error) {
console.error('Failed to hide all rows:', error);
}
};
const handleReset = () => {
try {
const newConfigs = { ...localConfigs };
Object.keys(newConfigs).forEach(rowId => {
newConfigs[rowId] = { ...newConfigs[rowId], isVisible: true };
});
setLocalConfigs(newConfigs);
onConfigChange(newConfigs);
} catch (error) {
console.error('Failed to reset configuration:', error);
}
};
// 拖拽排序处理
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnter = (index: number) => {
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) {
return;
}
try {
const sortedEntries = Object.entries(localConfigs)
.sort(([, a], [, b]) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
const [draggedItem] = sortedEntries.splice(draggedIndex, 1);
sortedEntries.splice(dropIndex, 0, draggedItem);
const newConfigs = { ...localConfigs };
sortedEntries.forEach(([rowId], index) => {
newConfigs[rowId] = { ...newConfigs[rowId], displayOrder: index };
});
setLocalConfigs(newConfigs);
onConfigChange(newConfigs);
if (onRowOrderChange) {
const newOrder = sortedEntries.map(([rowId]) => rowId);
onRowOrderChange(newOrder);
}
} catch (error) {
console.error('Failed to reorder rows:', error);
}
};
// 删除自定义行处理
const handleDeleteCustomRow = (rowId: string) => {
try {
if (onDeleteCustomRow) {
onDeleteCustomRow(rowId);
}
const newConfigs = { ...localConfigs };
delete newConfigs[rowId];
setLocalConfigs(newConfigs);
onConfigChange(newConfigs);
} catch (error) {
console.error('Failed to delete custom row:', error);
}
};
if (!isOpen) return null;
const visibleCount = Object.values(localConfigs).filter(config => config.isVisible).length;
const totalCount = Object.keys(localConfigs).length;
const canHideMore = visibleCount > 1;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] flex flex-col animate-in fade-in-0 zoom-in-95 duration-200">
{/* 头部 */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b">
<div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-500 mt-1">
{visibleCount} / {totalCount}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 hover:bg-gray-100 rounded"
>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-2 p-4 sm:p-6 border-b bg-gray-50">
<Button
variant="outline"
size="sm"
onClick={handleShowAll}
className="flex-1 text-xs sm:text-sm"
>
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleHideAll}
disabled={!canHideMore}
className="flex-1 text-xs sm:text-sm"
title={!canHideMore ? "至少需要保留一行可见" : "隐藏所有行"}
>
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="flex-1 text-xs sm:text-sm"
>
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
</Button>
</div>
{/* 行配置列表 */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-2">
{enableRowReordering ? (
// 可拖拽排序的行列表
Object.entries(localConfigs)
.sort(([, a], [, b]) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0))
.map(([rowId, config], index) => (
<SortableRowItem
key={rowId}
rowId={rowId}
displayText={rowDisplayTexts[rowId] || rowId}
config={config}
index={index}
isDragging={draggedIndex === index}
isDragOver={dragOverIndex === index}
onVisibilityChange={handleVisibilityChange}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
onDeleteCustomRow={handleDeleteCustomRow}
enableDragging={enableRowReordering}
/>
))
) : (
// 传统的行列表(不可拖拽)
Object.entries(localConfigs)
.sort(([, a], [, b]) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0))
.map(([rowId, config]) => (
<RowSettings
key={rowId}
rowId={rowId}
displayText={rowDisplayTexts[rowId] || rowId}
isVisible={config.isVisible}
onVisibilityChange={handleVisibilityChange}
/>
))
)}
</div>
{/* 底部 */}
<div className="p-4 sm:p-6 border-t bg-gray-50">
{/* 警告信息 */}
{visibleCount <= 1 && (
<div className="mb-3 p-2 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-800">
<div className="flex items-center gap-1">
<svg className="h-3 w-3 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span></span>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
<div className="text-xs text-gray-500 text-center sm:text-left">
访
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span> {visibleCount}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
<span> {totalCount - visibleCount}</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// 设置按钮组件
export function RowSettingsButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="inline-flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
title="配置表格行显示"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
</button>
);
}

View File

@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,211 @@
"use client";
import React, { useState } from "react";
import { cn } from "@/lib/utils";
import type { TableRowConfig } from "./row-settings";
// ============================================================================
// 类型定义
// ============================================================================
export interface SortableRowItemProps {
/** 行ID */
rowId: string;
/** 显示文本 */
displayText: string;
/** 行配置 */
config: TableRowConfig;
/** 行索引 */
index: number;
/** 是否正在拖拽 */
isDragging: boolean;
/** 是否为拖拽目标 */
isDragOver: boolean;
/** 可见性变更回调 */
onVisibilityChange: (rowId: string, visible: boolean) => void;
/** 拖拽开始回调 */
onDragStart: (index: number) => void;
/** 拖拽结束回调 */
onDragEnd: () => void;
/** 拖拽进入回调 */
onDragEnter: (index: number) => void;
/** 拖拽离开回调 */
onDragLeave: () => void;
/** 拖拽悬停回调 */
onDragOver: (e: React.DragEvent) => void;
/** 放置回调 */
onDrop: (e: React.DragEvent) => void;
/** 删除自定义行回调 */
onDeleteCustomRow?: (rowId: string) => void;
/** 是否启用拖拽排序 */
enableDragging?: boolean;
}
// ============================================================================
// 可排序行项目组件
// ============================================================================
export function SortableRowItem({
rowId,
displayText,
config,
index,
isDragging,
isDragOver,
onVisibilityChange,
onDragStart,
onDragEnd,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onDeleteCustomRow,
enableDragging = true
}: SortableRowItemProps) {
const [isHovered, setIsHovered] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
if (!enableDragging) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', rowId);
onDragStart(index);
};
const handleDeleteCustomRow = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteCustomRow && config.isCustomRow) {
onDeleteCustomRow(rowId);
}
};
const getRowTypeIcon = () => {
if (!config.isCustomRow) return null;
switch (config.customRowType) {
case 'empty':
return (
<svg className="h-4 w-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
);
case 'separator':
return (
<svg className="h-4 w-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
);
case 'note':
return (
<svg className="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
);
default:
return null;
}
};
return (
<div
draggable={enableDragging}
className={cn(
"flex items-center justify-between py-3 px-4 rounded-lg transition-all duration-200 border group",
config.isVisible ? "border-green-200 bg-green-50/30" : "border-gray-200 bg-gray-50/30",
isDragging && "opacity-50 scale-95 rotate-2",
isDragOver && "bg-blue-50 border-blue-300 border-t-2",
isHovered && !isDragging && "shadow-sm",
config.isCustomRow && "border-dashed"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
onDragEnter={() => onDragEnter(index)}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* 拖拽手柄 */}
{enableDragging && (
<div className={cn(
"cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded transition-opacity",
isHovered ? "opacity-100" : "opacity-0"
)}>
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</div>
)}
{/* 状态指示器 */}
<div className={cn(
"w-2 h-2 rounded-full flex-shrink-0",
config.isVisible ? "bg-green-500" : "bg-gray-400"
)} />
{/* 行类型图标 */}
{getRowTypeIcon()}
{/* 显示文本 */}
<span className={cn(
"text-sm font-medium truncate",
config.isVisible ? "text-gray-900" : "text-gray-500",
config.isCustomRow && "italic"
)}>
{displayText}
{config.isCustomRow && (
<span className="text-xs text-gray-400 ml-2">
({config.customRowType === 'empty' && '空行'})
({config.customRowType === 'separator' && '分隔线'})
({config.customRowType === 'note' && '备注'})
</span>
)}
</span>
{/* 排序序号 */}
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">
#{(config.displayOrder ?? 0) + 1}
</span>
</div>
<div className="flex items-center gap-2 ml-3">
{/* 删除自定义行按钮 */}
{config.isCustomRow && onDeleteCustomRow && (
<button
onClick={handleDeleteCustomRow}
className="p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="删除此行"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</button>
)}
{/* 可见性切换开关 */}
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.isVisible}
onChange={(e) => onVisibilityChange(rowId, e.target.checked)}
className="sr-only"
/>
<div className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2",
config.isVisible ? "bg-blue-600" : "bg-gray-300"
)}>
<span className={cn(
"inline-block h-5 w-5 transform rounded-full bg-white shadow-lg transition-transform duration-200 ease-in-out",
config.isVisible ? "translate-x-5" : "translate-x-0.5"
)} />
</div>
</label>
</div>
</div>
);
}

View File

@ -0,0 +1,325 @@
"use client";
/**
*
*
*
* -
* - /
* -
* -
* -
*
* @author Financial Analysis Platform Team
* @version 1.0.0
*/
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
// ============================================================================
// 类型定义
// ============================================================================
/**
*
*/
export interface ExecutionStep {
/** 步骤唯一标识符 */
id: string;
/** 步骤显示名称 */
name: string;
/** 步骤详细描述 */
description: string;
}
/**
*
*/
export interface StatusBarProps {
/** 是否可见 */
isVisible: boolean;
/** 当前执行步骤 */
currentStep: ExecutionStep | null;
/** 当前步骤索引 */
stepIndex: number;
/** 总步骤数 */
totalSteps: number;
/** 执行状态 */
status: 'idle' | 'loading' | 'success' | 'error';
/** 错误信息 */
errorMessage?: string;
/** 关闭回调 */
onDismiss?: () => void;
/** 重试回调 */
onRetry?: () => void;
/** 是否可重试 */
retryable?: boolean;
}
/**
*
*/
export interface StatusBarState {
/** 是否可见 */
isVisible: boolean;
/** 当前执行步骤 */
currentStep: ExecutionStep | null;
/** 当前步骤索引 */
stepIndex: number;
/** 总步骤数 */
totalSteps: number;
/** 执行状态 */
status: 'idle' | 'loading' | 'success' | 'error';
/** 错误信息 */
errorMessage?: string;
/** 是否可重试 */
retryable?: boolean;
}
// 预定义的执行步骤已移至 ExecutionStepManager
// ============================================================================
// 主组件
// ============================================================================
/**
*
*
*
*
* @param props -
* @returns JSX元素
*/
export function StatusBar({
isVisible,
currentStep,
stepIndex,
totalSteps,
status,
errorMessage,
onDismiss,
onRetry,
retryable = false
}: StatusBarProps) {
const [shouldRender, setShouldRender] = useState(isVisible);
// 处理显示/隐藏动画
useEffect(() => {
if (isVisible) {
setShouldRender(true);
} else {
// 延迟隐藏以完成动画
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isVisible]);
// 自动隐藏成功状态
useEffect(() => {
if (status === 'success' && onDismiss) {
const timer = setTimeout(() => {
onDismiss();
}, 2000);
return () => clearTimeout(timer);
}
}, [status, onDismiss]);
// 错误状态自动超时隐藏(可选)
useEffect(() => {
if (status === 'error' && onDismiss) {
const timer = setTimeout(() => {
onDismiss();
}, 10000); // 10秒后自动隐藏错误信息
return () => clearTimeout(timer);
}
}, [status, onDismiss]);
if (!shouldRender) return null;
return (
<div
className={cn(
"fixed top-20 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300 ease-in-out",
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
)}
>
<div className={cn(
"bg-white border rounded-lg shadow-lg px-4 py-3 min-w-80 max-w-md",
status === 'error' && "border-red-200 bg-red-50",
status === 'success' && "border-green-200 bg-green-50",
status === 'loading' && "border-blue-200 bg-blue-50"
)}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{/* 状态图标 */}
<div className="flex-shrink-0">
{status === 'loading' && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent" />
)}
{status === 'success' && (
<div className="rounded-full h-4 w-4 bg-green-500 flex items-center justify-center">
<svg className="h-2.5 w-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{status === 'error' && (
<div className="rounded-full h-4 w-4 bg-red-500 flex items-center justify-center">
<svg className="h-2.5 w-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
{/* 状态文本 */}
<div className="flex-1">
<div className={cn(
"text-sm font-medium",
status === 'error' && "text-red-800",
status === 'success' && "text-green-800",
status === 'loading' && "text-blue-800"
)}>
{status === 'error' ? '执行失败' :
status === 'success' ? '执行完成' :
currentStep?.name || '正在执行...'}
</div>
{status === 'error' && errorMessage && (
<div className="text-xs text-red-600 mt-1 max-w-sm">
{errorMessage}
{errorMessage.includes('网络') || errorMessage.includes('连接') ? (
<div className="mt-1 text-xs text-red-500">
</div>
) : null}
</div>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 ml-2">
{/* 重试按钮 */}
{status === 'error' && retryable && onRetry && (
<button
onClick={onRetry}
className="flex-shrink-0 px-2 py-1 text-xs bg-red-100 text-red-700 hover:bg-red-200 rounded transition-colors"
title="重试"
>
</button>
)}
{/* 关闭按钮 */}
{(status === 'error' || status === 'success') && onDismiss && (
<button
onClick={onDismiss}
className={cn(
"flex-shrink-0 rounded-full p-1 hover:bg-gray-100 transition-colors",
status === 'error' && "hover:bg-red-100",
status === 'success' && "hover:bg-green-100"
)}
>
<svg className="h-3 w-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</div>
{/* 进度指示器 */}
{status === 'loading' && totalSteps > 1 && (
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span> {stepIndex + 1} / {totalSteps}</span>
<span>{Math.round(((stepIndex + 1) / totalSteps) * 100)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300 ease-out"
style={{ width: `${((stepIndex + 1) / totalSteps) * 100}%` }}
/>
</div>
</div>
)}
</div>
</div>
);
}
// ============================================================================
// 状态管理Hook
// ============================================================================
/**
* Hook
*
*
*
* @returns
*/
export function useStatusBar() {
const [statusBarState, setStatusBarState] = useState<StatusBarState>({
isVisible: false,
currentStep: null,
stepIndex: 0,
totalSteps: 1,
status: 'idle',
errorMessage: undefined,
retryable: false
});
const showStatusBar = (step: ExecutionStep, stepIndex: number = 0, totalSteps: number = 1) => {
setStatusBarState({
isVisible: true,
currentStep: step,
stepIndex,
totalSteps,
status: 'loading',
errorMessage: undefined
});
};
const updateStep = (step: ExecutionStep, stepIndex: number) => {
setStatusBarState(prev => ({
...prev,
currentStep: step,
stepIndex,
status: 'loading'
}));
};
const showSuccess = () => {
setStatusBarState(prev => ({
...prev,
status: 'success'
}));
};
const showError = (errorMessage: string, retryable: boolean = false) => {
setStatusBarState(prev => ({
...prev,
status: 'error',
errorMessage,
retryable
}));
};
const hideStatusBar = () => {
setStatusBarState(prev => ({
...prev,
isVisible: false
}));
};
return {
statusBarState,
showStatusBar,
updateStep,
showSuccess,
showError,
hideStatusBar
};
}

View File

@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
isVisible?: boolean;
animationDuration?: number;
}
const TableRow = React.forwardRef<
HTMLTableRowElement,
TableRowProps
>(({ className, isVisible = true, animationDuration = 300, style, ...props }, ref) => {
const [shouldRender, setShouldRender] = React.useState(isVisible);
const [isAnimating, setIsAnimating] = React.useState(false);
React.useEffect(() => {
if (isVisible && !shouldRender) {
// Show animation
setShouldRender(true);
setIsAnimating(true);
const timer = setTimeout(() => setIsAnimating(false), animationDuration);
return () => clearTimeout(timer);
} else if (!isVisible && shouldRender) {
// Hide animation
setIsAnimating(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsAnimating(false);
}, animationDuration);
return () => clearTimeout(timer);
}
}, [isVisible, shouldRender, animationDuration]);
if (!shouldRender) {
return null;
}
const animationStyle: React.CSSProperties = {
...style,
transition: `all ${animationDuration}ms ease-in-out`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-10px)',
maxHeight: isVisible ? '1000px' : '0px',
overflow: 'hidden',
};
return (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
isAnimating && "pointer-events-none",
className
)}
style={animationStyle}
{...props}
/>
);
})
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-2 align-middle whitespace-nowrap", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,522 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { TableRowConfig } from "@/components/ui/row-settings";
// 配置存储的键前缀
const CONFIG_STORAGE_PREFIX = "table_row_config_";
// 默认配置生成函数
function createDefaultConfig(rowIds: string[]): Record<string, TableRowConfig> {
const config: Record<string, TableRowConfig> = {};
rowIds.forEach((rowId, index) => {
config[rowId] = {
rowId,
isVisible: true,
displayOrder: index
};
});
return config;
}
// 检查localStorage是否可用
function isLocalStorageAvailable(): boolean {
try {
const testKey = '__localStorage_test__';
localStorage.setItem(testKey, 'test');
localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
// 从localStorage加载配置
function loadConfigFromStorage(companyCode: string, rowIds: string[]): {
config: Record<string, TableRowConfig>;
customRows: Record<string, { displayText: string; customRowType: 'separator' }>;
} {
// 检查localStorage是否可用
if (!isLocalStorageAvailable()) {
console.warn('localStorage is not available, using default config');
return {
config: createDefaultConfig(rowIds),
customRows: {}
};
}
try {
const storageKey = `${CONFIG_STORAGE_PREFIX}${companyCode}`;
const stored = localStorage.getItem(storageKey);
if (!stored) {
return {
config: createDefaultConfig(rowIds),
customRows: {}
};
}
const parsed = JSON.parse(stored);
// 检查配置版本兼容性
if (parsed._version && parsed._version !== '1.0') {
console.warn('Incompatible config version, resetting to default');
return {
config: createDefaultConfig(rowIds),
customRows: {}
};
}
// 验证配置完整性确保所有rowId都存在
const config: Record<string, TableRowConfig> = {};
let hasInvalidConfig = false;
rowIds.forEach((rowId, index) => {
if (parsed[rowId] && typeof parsed[rowId].isVisible === 'boolean') {
config[rowId] = {
rowId,
isVisible: parsed[rowId].isVisible,
displayOrder: parsed[rowId].displayOrder ?? index
};
} else {
// 如果配置不存在或无效,使用默认值
config[rowId] = {
rowId,
isVisible: true,
displayOrder: index
};
hasInvalidConfig = true;
}
});
// 加载自定义行数据
const customRows = parsed._customRows || {};
// 将自定义行也添加到配置中
Object.entries(customRows).forEach(([rowId, customRow]) => {
if (customRow && typeof customRow === 'object' && 'customRowType' in customRow && customRow.customRowType === 'separator') {
config[rowId] = {
rowId,
isVisible: parsed[rowId]?.isVisible ?? true,
displayOrder: parsed[rowId]?.displayOrder ?? Object.keys(config).length,
isCustomRow: true,
customRowType: 'separator' as const
};
}
});
// 如果有无效配置,保存修复后的配置
if (hasInvalidConfig) {
saveConfigToStorage(companyCode, config, customRows);
}
return { config, customRows };
} catch (error) {
console.warn('Failed to load row config from localStorage:', error);
// 尝试清除损坏的配置
try {
const storageKey = `${CONFIG_STORAGE_PREFIX}${companyCode}`;
localStorage.removeItem(storageKey);
} catch {
// 忽略清除失败
}
return {
config: createDefaultConfig(rowIds),
customRows: {}
};
}
}
// 保存配置到localStorage
function saveConfigToStorage(
companyCode: string,
config: Record<string, TableRowConfig>,
customRows?: Record<string, { displayText: string; customRowType: 'separator' }>
): { success: boolean; error?: string } {
if (!isLocalStorageAvailable()) {
const error = 'localStorage不可用配置无法持久化保存';
console.warn(error);
return { success: false, error };
}
try {
const storageKey = `${CONFIG_STORAGE_PREFIX}${companyCode}`;
const configWithVersion = {
...config,
_customRows: customRows || {},
_version: '1.0',
_timestamp: Date.now()
};
localStorage.setItem(storageKey, JSON.stringify(configWithVersion));
return { success: true };
} catch (error) {
console.warn('Failed to save row config to localStorage:', error);
// 如果是存储空间不足,尝试清理旧配置
if (error instanceof Error && error.name === 'QuotaExceededError') {
try {
cleanupOldConfigs();
// 重试保存
const configWithVersion = {
...config,
_version: '1.0',
_timestamp: Date.now()
};
const storageKey = `${CONFIG_STORAGE_PREFIX}${companyCode}`;
localStorage.setItem(storageKey, JSON.stringify(configWithVersion));
return { success: true };
} catch (retryError) {
const errorMsg = '存储空间不足,清理后仍无法保存配置';
console.warn(errorMsg, retryError);
return { success: false, error: errorMsg };
}
}
const errorMsg = error instanceof Error ? error.message : '配置保存失败';
return { success: false, error: errorMsg };
}
}
// 清理旧的配置数据
function cleanupOldConfigs(): void {
try {
const keysToRemove: string[] = [];
const cutoffTime = Date.now() - (30 * 24 * 60 * 60 * 1000); // 30天前
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(CONFIG_STORAGE_PREFIX)) {
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed._timestamp && parsed._timestamp < cutoffTime) {
keysToRemove.push(key);
}
}
} catch {
// 如果解析失败,也标记为删除
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
console.log(`Cleaned up ${keysToRemove.length} old config entries`);
} catch (error) {
console.warn('Failed to cleanup old configs:', error);
}
}
// 配置保存状态接口
export interface ConfigSaveStatus {
status: 'idle' | 'saving' | 'success' | 'error';
message?: string;
timestamp?: number;
}
// 行配置管理Hook
export function useRowConfig(companyCode: string | null, rowIds: string[]) {
// Create a stable reference for rowIds to prevent unnecessary re-renders
const stableRowIds = useMemo(() => rowIds, [rowIds]);
const [rowConfigs, setRowConfigs] = useState<Record<string, TableRowConfig>>(() => {
// 初始化时如果有公司代码,尝试加载配置
if (companyCode && stableRowIds.length > 0) {
const { config } = loadConfigFromStorage(companyCode, stableRowIds);
return config;
}
return createDefaultConfig(stableRowIds);
});
// 自定义行数据存储
const [customRows, setCustomRows] = useState<Record<string, { displayText: string; customRowType: 'separator' }>>(() => {
// 初始化时如果有公司代码,尝试加载自定义行数据
if (companyCode && stableRowIds.length > 0) {
const { customRows } = loadConfigFromStorage(companyCode, stableRowIds);
return customRows;
}
return {};
});
// 配置保存状态
const [saveStatus, setSaveStatus] = useState<ConfigSaveStatus>({
status: 'idle'
});
// 当公司代码或行ID变化时重新加载配置
useEffect(() => {
if (companyCode && stableRowIds.length > 0) {
const { config, customRows: loadedCustomRows } = loadConfigFromStorage(companyCode, stableRowIds);
setRowConfigs(config);
setCustomRows(loadedCustomRows);
} else if (stableRowIds.length > 0) {
// 没有公司代码时使用默认配置
setRowConfigs(createDefaultConfig(stableRowIds));
setCustomRows({});
}
}, [companyCode, stableRowIds]);
// 安全保存配置的内部函数
const saveConfigSafely = useCallback((config: Record<string, TableRowConfig>, customRowsData?: Record<string, { displayText: string; customRowType: 'separator' }>) => {
if (!companyCode) return;
setSaveStatus({ status: 'saving' });
// 使用 setTimeout 来模拟异步保存避免阻塞UI
setTimeout(() => {
const result = saveConfigToStorage(companyCode, config, customRowsData || customRows);
if (result.success) {
setSaveStatus({
status: 'success',
message: '配置已保存',
timestamp: Date.now()
});
// 成功状态2秒后自动清除
setTimeout(() => {
setSaveStatus(prev => prev.status === 'success' ? { status: 'idle' } : prev);
}, 2000);
} else {
setSaveStatus({
status: 'error',
message: result.error || '配置保存失败',
timestamp: Date.now()
});
// 错误状态5秒后自动清除
setTimeout(() => {
setSaveStatus(prev => prev.status === 'error' ? { status: 'idle' } : prev);
}, 5000);
}
}, 0);
}, [companyCode, customRows]);
// 更新配置
const updateRowConfig = useCallback((newConfig: Record<string, TableRowConfig>) => {
setRowConfigs(newConfig);
// 如果有公司代码保存到localStorage
if (companyCode) {
saveConfigSafely(newConfig, customRows);
}
}, [companyCode, saveConfigSafely, customRows]);
// 切换单个行的可见性
const toggleRowVisibility = useCallback((rowId: string) => {
setRowConfigs(prev => {
const newConfig = {
...prev,
[rowId]: {
...prev[rowId],
isVisible: !prev[rowId]?.isVisible
}
};
if (companyCode) {
saveConfigSafely(newConfig, customRows);
}
return newConfig;
});
}, [companyCode, saveConfigSafely, customRows]);
// 重置配置为默认值
const resetConfig = useCallback(() => {
const defaultConfig = createDefaultConfig(stableRowIds);
const emptyCustomRows = {};
setRowConfigs(defaultConfig);
setCustomRows(emptyCustomRows);
if (companyCode) {
saveConfigSafely(defaultConfig, emptyCustomRows);
}
}, [companyCode, stableRowIds, saveConfigSafely]);
// 获取可见的行ID列表
const visibleRowIds = Object.entries(rowConfigs)
.filter(([, config]) => config.isVisible)
.sort((a, b) => (a[1].displayOrder ?? 0) - (b[1].displayOrder ?? 0))
.map(([rowId]) => rowId);
// 导出配置
const exportConfig = useCallback(() => {
if (!companyCode) return null;
return {
companyCode,
config: rowConfigs,
exportedAt: new Date().toISOString(),
version: '1.0'
};
}, [companyCode, rowConfigs]);
// 导入配置
const importConfig = useCallback((importedData: unknown) => {
try {
if (!importedData || typeof importedData !== 'object') {
throw new Error('Invalid import data format');
}
const data = importedData as Record<string, unknown>;
if (!data.config || !data.companyCode) {
throw new Error('Invalid import data format');
}
if (data.companyCode !== companyCode) {
console.warn('Imported config is for a different company');
return false;
}
// 验证导入的配置
const importedConfig = data.config as Record<string, unknown>;
const validatedConfig: Record<string, TableRowConfig> = {};
stableRowIds.forEach((rowId, index) => {
const rowConfig = importedConfig[rowId] as Record<string, unknown> | undefined;
if (rowConfig && typeof rowConfig.isVisible === 'boolean') {
validatedConfig[rowId] = {
rowId,
isVisible: rowConfig.isVisible,
displayOrder: typeof rowConfig.displayOrder === 'number' ? rowConfig.displayOrder : index
};
} else {
validatedConfig[rowId] = {
rowId,
isVisible: true,
displayOrder: index
};
}
});
setRowConfigs(validatedConfig);
if (companyCode) {
saveConfigSafely(validatedConfig);
}
return true;
} catch (error) {
console.warn('Failed to import config:', error);
return false;
}
}, [companyCode, stableRowIds]);
// 获取配置统计信息
const getConfigStats = useCallback(() => {
const total = Object.keys(rowConfigs).length;
const visible = Object.values(rowConfigs).filter(config => config.isVisible).length;
const hidden = total - visible;
return {
total,
visible,
hidden,
visibilityRate: total > 0 ? Math.round((visible / total) * 100) : 0
};
}, [rowConfigs]);
// 添加自定义行(仅支持分隔线)
const addCustomRow = useCallback((rowType: 'separator', displayText?: string) => {
// 强制只支持分隔线类型
const actualRowType = 'separator';
const rowId = `separator_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const text = displayText || '分组标题';
// 添加到自定义行数据
setCustomRows(prev => ({
...prev,
[rowId]: { displayText: text, customRowType: actualRowType }
}));
// 添加到行配置
const newConfig: Record<string, TableRowConfig> = {
...rowConfigs,
[rowId]: {
rowId,
isVisible: true,
displayOrder: Object.keys(rowConfigs).length,
isCustomRow: true,
customRowType: actualRowType
}
};
setRowConfigs(newConfig);
if (companyCode) {
// 获取更新后的自定义行数据
const updatedCustomRows: Record<string, { displayText: string; customRowType: 'separator' }> = {
...customRows,
[rowId]: { displayText: text, customRowType: 'separator' }
};
saveConfigSafely(newConfig, updatedCustomRows);
}
return rowId;
}, [rowConfigs, customRows, companyCode, saveConfigSafely]);
// 删除自定义行
const deleteCustomRow = useCallback((rowId: string) => {
if (!rowConfigs[rowId]?.isCustomRow) {
console.warn('Cannot delete non-custom row:', rowId);
return;
}
// 从自定义行数据中删除
const updatedCustomRows = { ...customRows };
delete updatedCustomRows[rowId];
setCustomRows(updatedCustomRows);
// 从行配置中删除
const newConfig = { ...rowConfigs };
delete newConfig[rowId];
setRowConfigs(newConfig);
if (companyCode) {
saveConfigSafely(newConfig, updatedCustomRows);
}
}, [rowConfigs, customRows, companyCode, saveConfigSafely]);
// 更新行顺序
const updateRowOrder = useCallback((newOrder: string[]) => {
const newConfig = { ...rowConfigs };
newOrder.forEach((rowId, index) => {
if (newConfig[rowId]) {
newConfig[rowId] = { ...newConfig[rowId], displayOrder: index };
}
});
setRowConfigs(newConfig);
if (companyCode) {
saveConfigSafely(newConfig, customRows);
}
}, [rowConfigs, customRows, companyCode, saveConfigSafely]);
// 清除保存状态
const clearSaveStatus = useCallback(() => {
setSaveStatus({ status: 'idle' });
}, []);
return {
rowConfigs,
customRows,
updateRowConfig,
toggleRowVisibility,
resetConfig,
visibleRowIds,
exportConfig,
importConfig,
getConfigStats,
saveStatus,
clearSaveStatus,
addCustomRow,
deleteCustomRow,
updateRowOrder
};
}

334
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,334 @@
/**
*
* 使
*/
// ============================================================================
// 基础数据类型
// ============================================================================
/**
*
*/
export type MarketType = 'cn' | 'us' | 'hk' | 'jp' | 'other';
/**
*
*/
export type ChartType = 'bar' | 'line';
/**
*
*/
export interface CompanyInfo {
/** 股票代码 (如: 600519.SH) */
ts_code: string;
/** 公司名称 */
name?: string;
}
/**
*
*/
export interface CompanySuggestion {
/** 股票代码 */
ts_code: string;
/** 公司名称 */
name: string;
}
// ============================================================================
// 财务数据类型
// ============================================================================
/**
*
*/
export interface YearDataPoint {
/** 年份 */
year: string;
/** 数值 (可为null表示无数据) */
value: number | null;
}
/**
* ()
*/
export interface RevenueDataPoint {
/** 年份 */
year: string;
/** 收入值 (可为null表示无数据) */
revenue: number | null;
}
/**
*
*/
export interface FinancialMetricConfig {
/** 显示文本 */
displayText: string;
/** Tushare API参数名 */
tushareParam: string;
/** API接口名 */
api?: string;
/** 指标分组 */
group?: string;
}
/**
*
*/
export interface FinancialDataSeries {
[metricKey: string]: YearDataPoint[];
}
/**
*
*/
export interface BatchFinancialDataResponse {
/** 股票代码 */
ts_code: string;
/** 公司名称 */
name?: string;
/** 数据系列 */
series: FinancialDataSeries;
}
/**
*
*/
export interface FinancialConfigResponse {
/** API分组配置 */
api_groups: {
[groupName: string]: FinancialMetricConfig[];
};
}
// ============================================================================
// 表格相关类型
// ============================================================================
/**
*
*/
export interface TableRowData {
/** 行唯一标识符 */
id: string;
/** 显示文本 */
displayText: string;
/** 各年份的值 */
values: Record<string, string | number | null>;
/** 指标分组 */
group?: string;
/** API接口名 */
api?: string;
/** Tushare参数名 */
tushareParam?: string;
}
/**
*
*/
export interface TableRowConfig {
/** 行ID */
rowId: string;
/** 是否可见 */
isVisible: boolean;
/** 显示顺序 */
displayOrder?: number;
}
/**
*
*/
export type TableRowConfigs = Record<string, TableRowConfig>;
// ============================================================================
// 状态管理类型
// ============================================================================
/**
*
*/
export interface ExecutionStep {
/** 步骤唯一标识符 */
id: string;
/** 步骤显示名称 */
name: string;
/** 步骤描述 */
description: string;
/** 执行函数 (可选) */
execute?: () => Promise<void>;
}
/**
*
*/
export type StatusBarStatus = 'idle' | 'loading' | 'success' | 'error';
/**
*
*/
export interface StatusBarState {
/** 是否可见 */
isVisible: boolean;
/** 当前执行步骤 */
currentStep: ExecutionStep | null;
/** 当前步骤索引 */
stepIndex: number;
/** 总步骤数 */
totalSteps: number;
/** 执行状态 */
status: StatusBarStatus;
/** 错误信息 */
errorMessage?: string;
/** 是否可重试 */
retryable?: boolean;
}
/**
*
*/
export type ConfigSaveStatus = 'idle' | 'saving' | 'success' | 'error';
/**
*
*/
export interface ConfigSaveState {
/** 保存状态 */
status: ConfigSaveStatus;
/** 状态消息 */
message?: string;
/** 时间戳 */
timestamp?: number;
}
// ============================================================================
// API 相关类型
// ============================================================================
/**
* API
*/
export interface ApiErrorResponse {
/** 错误代码 */
code?: string;
/** 错误消息 */
message: string;
/** 详细信息 */
details?: unknown;
}
/**
* API响应接口
*/
export interface SearchApiResponse {
/** 搜索结果项目 */
items: CompanySuggestion[];
/** 总数量 */
total?: number;
}
/**
*
*/
export interface BatchDataRequest {
/** 股票代码 */
ts_code: string;
/** 年份数量 */
years: number;
/** 指标列表 */
metrics: string[];
}
// ============================================================================
// 组件属性类型
// ============================================================================
/**
*
*/
export interface BaseComponentProps {
/** CSS类名 */
className?: string;
/** 子元素 */
children?: React.ReactNode;
}
/**
*
*/
export interface ControlledComponentProps<T> {
/** 当前值 */
value: T;
/** 值变更回调 */
onChange: (value: T) => void;
}
/**
*
*/
export interface AsyncOperationProps {
/** 是否正在加载 */
loading?: boolean;
/** 错误信息 */
error?: string;
/** 重试回调 */
onRetry?: () => void;
}
// ============================================================================
// 工具类型
// ============================================================================
/**
*
*/
export type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
/**
*
*/
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
/**
*
*/
export type Required<T, K extends keyof T> = T & { [P in K]-?: T[P] };
/**
*
*/
export type KeyValuePair<K extends string | number | symbol = string, V = unknown> = Record<K, V>;
// ============================================================================
// 常量类型
// ============================================================================
/**
*
*/
export const SUPPORTED_MARKETS: readonly MarketType[] = ['cn', 'us', 'hk', 'jp', 'other'] as const;
/**
*
*/
export const SUPPORTED_CHART_TYPES: readonly ChartType[] = ['bar', 'line'] as const;
/**
*
*/
export const DEFAULT_CONFIG = {
/** 默认查询年份数 */
DEFAULT_YEARS: 10,
/** 默认搜索建议数量 */
DEFAULT_SUGGESTION_LIMIT: 8,
/** 配置自动保存延迟 (毫秒) */
CONFIG_SAVE_DELAY: 500,
/** 成功状态显示时长 (毫秒) */
SUCCESS_DISPLAY_DURATION: 2000,
/** 错误状态显示时长 (毫秒) */
ERROR_DISPLAY_DURATION: 10000,
} as const;

View File

@ -0,0 +1,178 @@
/**
*
*
*/
// 模拟测试数据
const TEST_COMPANY_CODE = "600519.SH";
const TEST_ROW_IDS = ["revenue", "net_profit", "total_assets"];
/**
*
*/
export function testAutoSave() {
console.log("🧪 开始测试自动保存功能...");
// 清理之前的测试数据
const storageKey = `table_row_config_${TEST_COMPANY_CODE}`;
localStorage.removeItem(storageKey);
// 模拟配置数据
const testConfig = {
revenue: {
rowId: "revenue",
isVisible: true,
displayOrder: 0
},
net_profit: {
rowId: "net_profit",
isVisible: false,
displayOrder: 1
},
total_assets: {
rowId: "total_assets",
isVisible: true,
displayOrder: 2
},
separator_123: {
rowId: "separator_123",
isVisible: true,
displayOrder: 3,
isCustomRow: true,
customRowType: "separator"
}
};
const testCustomRows = {
separator_123: {
displayText: "测试分隔线",
customRowType: "separator" as const
}
};
// 保存测试配置
const configWithVersion = {
...testConfig,
_customRows: testCustomRows,
_version: '1.0',
_timestamp: Date.now()
};
localStorage.setItem(storageKey, JSON.stringify(configWithVersion));
console.log("✅ 配置已保存到 localStorage");
// 验证保存的数据
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
console.log("📄 保存的配置:", parsed);
// 检查关键字段
const checks = [
{ name: "版本信息", condition: parsed._version === '1.0' },
{ name: "时间戳", condition: typeof parsed._timestamp === 'number' },
{ name: "自定义行数据", condition: parsed._customRows && parsed._customRows.separator_123 },
{ name: "行配置", condition: parsed.revenue && parsed.net_profit && parsed.total_assets },
{ name: "分隔线配置", condition: parsed.separator_123 && parsed.separator_123.isCustomRow }
];
checks.forEach(check => {
console.log(check.condition ? "✅" : "❌", check.name);
});
if (checks.every(check => check.condition)) {
console.log("🎉 自动保存功能测试通过!");
return true;
} else {
console.log("❌ 自动保存功能测试失败!");
return false;
}
} else {
console.log("❌ 未找到保存的配置数据!");
return false;
}
}
/**
*
*/
export function testAutoRestore() {
console.log("🔄 测试配置恢复功能...");
const storageKey = `table_row_config_${TEST_COMPANY_CODE}`;
const saved = localStorage.getItem(storageKey);
if (!saved) {
console.log("❌ 没有找到保存的配置,请先运行 testAutoSave()");
return false;
}
try {
const parsed = JSON.parse(saved);
// 验证恢复的数据结构
const hasConfig = parsed.revenue && parsed.net_profit && parsed.total_assets;
const hasCustomRows = parsed._customRows && parsed._customRows.separator_123;
const hasVersion = parsed._version === '1.0';
if (hasConfig && hasCustomRows && hasVersion) {
console.log("✅ 配置恢复成功!");
console.log("📊 恢复的行配置:", {
revenue: parsed.revenue,
net_profit: parsed.net_profit,
total_assets: parsed.total_assets,
separator_123: parsed.separator_123
});
console.log("🏷️ 恢复的自定义行:", parsed._customRows);
return true;
} else {
console.log("❌ 配置恢复失败,数据不完整!");
return false;
}
} catch (error) {
console.log("❌ 配置恢复失败JSON 解析错误:", error);
return false;
}
}
/**
*
*/
export function cleanupTestData() {
const storageKey = `table_row_config_${TEST_COMPANY_CODE}`;
localStorage.removeItem(storageKey);
console.log("🧹 测试数据已清理");
}
/**
*
*/
export function runAutoSaveTests() {
console.log("🚀 开始运行自动保存测试套件...");
console.log("=" .repeat(50));
const saveTest = testAutoSave();
const restoreTest = testAutoRestore();
console.log("=" .repeat(50));
console.log("📋 测试结果汇总:");
console.log("保存功能:", saveTest ? "✅ 通过" : "❌ 失败");
console.log("恢复功能:", restoreTest ? "✅ 通过" : "❌ 失败");
const allPassed = saveTest && restoreTest;
console.log("总体结果:", allPassed ? "🎉 全部通过" : "❌ 存在失败");
// 清理测试数据
cleanupTestData();
return allPassed;
}
// 在开发环境下自动运行测试
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
// 延迟执行,确保页面加载完成
setTimeout(() => {
console.log("🔧 开发环境检测到,可以在控制台运行以下命令测试自动保存功能:");
console.log("import { runAutoSaveTests } from './src/utils/test-auto-save'; runAutoSaveTests();");
}, 1000);
}

27
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

13
scripts/setup_all.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
# Forward CLEAN and USE_SYSTEM_PYTHON to sub-scripts
export CLEAN="${CLEAN:-0}"
export USE_SYSTEM_PYTHON="${USE_SYSTEM_PYTHON:-0}"
"$REPO_ROOT/scripts/setup_backend.sh"
"$REPO_ROOT/scripts/setup_frontend.sh"
echo "[all] environments setup completed."

66
scripts/setup_backend.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
BACKEND_DIR="$REPO_ROOT/backend"
VENV_DIR="$REPO_ROOT/.venv"
PYTHON_BIN="python3"
# Allow override: USE_SYSTEM_PYTHON=1 to skip venv
USE_SYSTEM_PYTHON="${USE_SYSTEM_PYTHON:-0}"
# CLEAN=1 to recreate venv
CLEAN="${CLEAN:-0}"
echo "[backend] repo: $REPO_ROOT"
echo "[backend] dir: $BACKEND_DIR"
echo "[backend] checking Python..."
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python)"
else
echo "[backend] ERROR: python3/python not found" >&2
exit 1
fi
echo "[backend] python: $PYTHON_BIN ($("$PYTHON_BIN" -V))"
if [[ "$USE_SYSTEM_PYTHON" == "1" ]]; then
echo "[backend] using system Python (no venv)"
else
if [[ "$CLEAN" == "1" && -d "$VENV_DIR" ]]; then
echo "[backend] CLEAN=1 -> removing existing venv: $VENV_DIR"
rm -rf "$VENV_DIR"
fi
if [[ ! -d "$VENV_DIR" ]]; then
echo "[backend] creating venv: $VENV_DIR"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# Activate venv
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
echo "[backend] venv activated: $VIRTUAL_ENV"
echo "[backend] upgrading pip/setuptools/wheel"
python -m pip install --upgrade pip setuptools wheel
fi
REQ_FILE="$BACKEND_DIR/requirements.txt"
if [[ ! -f "$REQ_FILE" ]]; then
echo "[backend] ERROR: requirements.txt not found at $REQ_FILE" >&2
exit 1
fi
echo "[backend] installing requirements: $REQ_FILE"
pip install -r "$REQ_FILE"
# Show versions
echo "[backend] python version: $(python -V)"
echo "[backend] pip version: $(python -m pip -V)"
if command -v uvicorn >/dev/null 2>&1; then
echo "[backend] uvicorn version: $(python -c 'import uvicorn,sys; print(uvicorn.__version__)' || echo unknown)"
else
echo "[backend] uvicorn not found; you may need it for dev server"
fi
echo "[backend] setup completed."

37
scripts/setup_frontend.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)"
FRONTEND_DIR="$REPO_ROOT/frontend"
CLEAN="${CLEAN:-0}"
cd "$FRONTEND_DIR"
echo "[frontend] repo: $REPO_ROOT"
echo "[frontend] dir: $FRONTEND_DIR"
if ! command -v node >/dev/null 2>&1; then
echo "[frontend] ERROR: node not found" >&2
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "[frontend] ERROR: npm not found" >&2
exit 1
fi
echo "[frontend] node: $(node -v)"
echo "[frontend] npm: $(npm -v)"
if [[ "$CLEAN" == "1" ]]; then
echo "[frontend] CLEAN=1 -> removing node_modules and .next/.turbo"
rm -rf node_modules .next .turbo
fi
if [[ -f package-lock.json ]]; then
echo "[frontend] detected package-lock.json -> using npm ci"
npm ci
else
echo "[frontend] no lockfile -> using npm install"
npm install
fi
echo "[frontend] setup completed."