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