Initial commit
This commit is contained in:
commit
4d7aa56b4b
413
.gitignore
vendored
Normal file
413
.gitignore
vendored
Normal 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/
|
||||
351
.kiro/specs/fundamental-stock-analysis/design.md
Normal file
351
.kiro/specs/fundamental-stock-analysis/design.md
Normal 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响应
|
||||
- 测试用例覆盖各种市场和股票类型
|
||||
112
.kiro/specs/fundamental-stock-analysis/requirements.md
Normal file
112
.kiro/specs/fundamental-stock-analysis/requirements.md
Normal 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. 当配置保存时,选股系统应当将配置持久化存储
|
||||
167
.kiro/specs/fundamental-stock-analysis/tasks.md
Normal file
167
.kiro/specs/fundamental-stock-analysis/tasks.md
Normal 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
20
config/config.json
Normal 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"
|
||||
}
|
||||
}
|
||||
62
config/financial-tushare.json
Normal file
62
config/financial-tushare.json
Normal 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
206
dev.py
Executable 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
158
docs/design.md
Normal 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请求,并允许实时进度追踪。
|
||||
|
||||
 <!-- 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
111
docs/requirements.md
Normal 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
74
docs/tasks.md
Normal 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
41
frontend/.gitignore
vendored
Normal 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
22
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
25
frontend/eslint.config.mjs
Normal file
25
frontend/eslint.config.mjs
Normal 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
17
frontend/next.config.mjs
Normal 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
7
frontend/next.config.ts
Normal 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
7272
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
frontend/postcss.config.mjs
Normal file
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
229
frontend/src/app/config/page.tsx
Normal file
229
frontend/src/app/config/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/docs/page.tsx
Normal file
26
frontend/src/app/docs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
frontend/src/app/globals.css
Normal file
122
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
58
frontend/src/app/layout.tsx
Normal file
58
frontend/src/app/layout.tsx
Normal 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
674
frontend/src/app/page.tsx
Normal 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>近10年指标(A股)</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>
|
||||
);
|
||||
}
|
||||
131
frontend/src/app/query/page.tsx
Normal file
131
frontend/src/app/query/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/ui/add-row-menu.tsx
Normal file
164
frontend/src/components/ui/add-row-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
60
frontend/src/components/ui/button.tsx
Normal file
60
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
289
frontend/src/components/ui/draggable-row.tsx
Normal file
289
frontend/src/components/ui/draggable-row.tsx
Normal 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";
|
||||
527
frontend/src/components/ui/enhanced-table.tsx
Normal file
527
frontend/src/components/ui/enhanced-table.tsx
Normal 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
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
168
frontend/src/components/ui/navigation-menu.tsx
Normal file
168
frontend/src/components/ui/navigation-menu.tsx
Normal 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,
|
||||
}
|
||||
119
frontend/src/components/ui/notification.tsx
Normal file
119
frontend/src/components/ui/notification.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
frontend/src/components/ui/row-settings.tsx
Normal file
403
frontend/src/components/ui/row-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/ui/select.tsx
Normal file
187
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
211
frontend/src/components/ui/sortable-row-item.tsx
Normal file
211
frontend/src/components/ui/sortable-row-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
frontend/src/components/ui/status-bar.tsx
Normal file
325
frontend/src/components/ui/status-bar.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
159
frontend/src/components/ui/table.tsx
Normal file
159
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
522
frontend/src/hooks/use-row-config.ts
Normal file
522
frontend/src/hooks/use-row-config.ts
Normal 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
334
frontend/src/types/index.ts
Normal 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;
|
||||
178
frontend/src/utils/test-auto-save.ts
Normal file
178
frontend/src/utils/test-auto-save.ts
Normal 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
27
frontend/tsconfig.json
Normal 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
13
scripts/setup_all.sh
Executable 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
66
scripts/setup_backend.sh
Executable 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
37
scripts/setup_frontend.sh
Executable 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."
|
||||
Loading…
Reference in New Issue
Block a user