Refactor frontend to Vite+React SPA and update docs

Major architectural shift from Next.js to a lightweight Vite + React SPA model ("Puppet Architecture") to better support real-time workflow visualization and strict type safety.

Key Changes:
1. **Architecture & Build**:
   - Initialized Vite + React + TypeScript project.
   - Configured Tailwind CSS v4 and Shadcn UI.
   - Archived legacy Next.js frontend to 'frontend/archive/v2_nextjs'.

2. **Core Features**:
   - **Dashboard**: Implemented startup page with Symbol, Market, and Template selection.
   - **Report Page**:
     - **Workflow Visualization**: Integrated ReactFlow to show dynamic DAG of analysis tasks.
     - **Real-time Status**: Implemented Mock SSE logic to simulate task progress, logs, and status changes.
     - **Multi-Tab Interface**: Dynamic tabs for 'Overview', 'Fundamental Data', and analysis modules.
     - **Streaming Markdown**: Enabled typewriter-style streaming rendering for analysis reports using 'react-markdown'.
   - **Config Page**: Implemented settings for AI Providers, Data Sources, and Templates using TanStack Query.

3. **Documentation**:
   - Created v2.0 User Guide ('docs/1_requirements/20251122_[Active]_user-guide_v2.md').
   - Implemented 'DocsPage' in frontend to render the user guide directly within the app.

4. **Backend Alignment**:
   - Created 'docs/frontend/backend_todos.md' outlining necessary backend adaptations (OpenAPI, Progress tracking).

This commit establishes the full frontend 'shell' ready for backend integration.
This commit is contained in:
Lv, Qi 2025-11-22 19:36:30 +08:00
parent 4881ac8603
commit b41eaf8b99
143 changed files with 23329 additions and 5060 deletions

View File

@ -52,7 +52,7 @@ services:
dockerfile: frontend/Dockerfile
container_name: fundamental-frontend
working_dir: /workspace/frontend
command: npm run dev
command: ["/workspace/frontend/scripts/docker-dev-entrypoint.sh"]
environment:
# 让 Next 的 API 路由代理到新的 api-gateway
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1

View File

@ -0,0 +1,63 @@
# Fundamental Analysis Platform 用户指南 (v2.0 - Vite Refactor)
日期: 2025-11-22
版本: 2.0
## 1. 简介
Fundamental Analysis Platform 是一个基于 AI Agent 的深度基本面投研平台,旨在通过自动化工作流聚合多源金融数据,并利用 LLM大语言模型生成专业的财务分析报告。
v2.0 版本采用了全新的 Vite + React SPA 架构,提供了更流畅的交互体验和实时的分析状态可视化。
## 2. 核心功能
### 2.1 仪表盘 (Dashboard)
平台首页,提供简洁的分析入口。
* **股票代码**: 支持输入 A股 (如 `600519.SS`)、美股 (如 `AAPL`) 或港股代码。
* **市场选择**: 下拉选择 CN (中国)、US (美国) 或 HK (香港)。
* **开始分析**: 点击“生成分析报告”按钮即可启动分析流程。
### 2.2 分析报告页 (Report View)
核心工作区,分为左侧状态栏和右侧详情区。
#### 左侧:工作流状态
* **可视化 DAG**: 展示当前的分析任务依赖图。
* **节点颜色**: 灰色(等待)、蓝色(运行中)、绿色(完成)、红色(失败)。
* **动态连线**: 当任务运行时,连接线会有流光动画指示数据流向。
* **实时日志**: 滚动展示所有后台任务的执行日志,支持实时查看数据抓取和分析进度。
#### 右侧:详情面板
* **Analysis Report**: 展示由 AI 生成的最终分析报告。支持 Markdown 格式(标题、表格、加粗、引用),并带有打字机生成特效。
* **Fundamental Data**: (开发中) 展示抓取到的原始财务数据表格。
* **Stock Chart**: (开发中) 展示股价走势图。
### 2.3 系统配置 (Config)
集中管理平台的所有外部连接和参数。
* **AI Provider**:
* 管理 LLM 供应商 (OpenAI, Anthropic, Local Ollama 等)。
* 配置 API Key 和 Base URL。
* 刷新并选择可用的模型 (GPT-4o, Claude-3.5 等)。
* **数据源配置**:
* 启用/禁用金融数据源 (Tushare, Finnhub, AlphaVantage)。
* 输入对应的 API Token。
* 支持连接测试。
* **分析模板**:
* 查看当前的分析流程模板(如 "Quick Scan")。
* 查看每个模块使用的 Prompt 模板及模型配置。
* **系统状态**:
* 监控微服务集群 (API Gateway, Orchestrator 等) 的健康状态。
## 3. 快速开始
1. 进入 **配置页** -> **AI Provider**,添加您的 OpenAI API Key。
2. 进入 **配置页** -> **数据源配置**,启用 Tushare 并输入 Token。
3. 回到 **首页**,输入 `600519.SS`,选择 `CN` 市场。
4. 点击 **生成分析报告**,观察工作流运行及报告生成。
## 4. 常见问题
* **Q: 报告生成卡住怎么办?**
* A: 检查左侧“实时日志”,查看是否有 API 连接超时或配额耗尽的错误。
* **Q: 如何添加本地模型?**
* A: 在 AI Provider 页添加新的 ProviderBase URL 填入 `http://localhost:11434/v1` (Ollama 默认地址)。

View File

@ -0,0 +1,400 @@
# 前端重构设计: Vite + React SPA
日期: 2025-11-22
状态: 已实现 (Vite + React 重构完成Mock 验证通过)
## 1. 概述与核心理念
本文档描述了将 `Fundamental_Analysis` 前端从 Next.js 重构为 Vite + React 的方案。
新的架构将专注于简单性、类型安全和“后端驱动”的状态模型。
### 核心理念
1. **木偶架构 (Puppet Architecture)**: 前端只包含最小的业务逻辑,它忠实反映后端的状态。
* 如果后端说“加载中”,我们就显示加载中。
* 如果后端发送了状态快照,我们就完全按照快照渲染。
* 不做任何猜测性的 UI 更新 (Optimistic UI)。
2. **单一数据源 (Single Source of Truth)**: 所有类型 (Structs, Enums) 由 Rust 定义并生成 TypeScript (Zod)。
* **前端禁止内联定义类型**。
* 使用 `zod` 对所有输入数据进行运行时校验 (Fail Early)。
3. **Rustic 风格**:
* 开启 Strict Mode。
* 禁止 `any` 类型。
* 对 Enum 进行穷尽匹配 (Exhaustive matching)。
4. **Vite + React**: SPA 架构,消除 SSR 代理黑盒,使 SSE 处理更加健壮和透明。
## 2. 技术栈
| 层级 | 技术 | 选择理由 |
| :--- | :--- | :--- |
| **构建工具** | **Vite** | 极速、简单、无隐藏代理黑盒。 |
| **框架** | **React 19** | 保留现有的 `shadcn/ui` 组件资产。 |
| **路由** | **React Router v7** | 标准的客户端路由。 |
| **状态管理** | **TanStack Query** | 服务端状态缓存 (用于配置数据)。 |
| **全局状态** | **Zustand** | 客户端 UI 状态 (Sidebar, Workflow) 管理。 |
| **UI 库** | **shadcn/ui** | 延续现有的设计语言 (Tailwind CSS)。 |
| **可视化** | **ReactFlow** | 用于渲染工作流 DAG 图。 |
| **数据请求** | **Axios + Zod** | 带运行时校验的类型化 HTTP 客户端。 |
| **API 生成** | **OpenAPI -> Zod** | 从后端自动生成前端类型。 |
## 3. 架构设计
### 3.1 类型生成流水线
后端 (Rust/Axum) 需暴露 `openapi.json` (通过 `utoipa` 等库)。
前端运行生成脚本:
`Rust Structs` -> `openapi.json` -> `frontend/src/api/schema.ts` (Zod schemas + TS Types)。
### 3.2 状态管理策略
#### A. 配置数据 (Pull 模型)
低频变更的数据 (API Keys, Templates) 由 **TanStack Query** 管理。
* `useQuery(['llm-providers'])`
* `useMutation(['update-provider'])`
#### B. 工作流数据 (Push/Stream 模型)
实时流动的数据 (分析进度) 由自定义的 **Workflow Store (Zustand)** 管理,直接连接 SSE。
* **连接层**: 使用原生 `EventSource` 直接连接 `http://api-gateway:4000/v1/workflow/events/{id}`
* **状态机**:
```typescript
type TaskInfo = {
id: string;
status: 'Pending' | 'Running' | 'Completed' | 'Failed';
progress?: number; // 0-100
error?: string;
logs: string[]; // 实时日志
};
type WorkflowState =
| { status: 'IDLE' }
| { status: 'CONNECTING', requestId: string }
| { status: 'RUNNING', requestId: string, dag: Dag, tasks: Record<string, TaskInfo> }
| { status: 'COMPLETED', result: any }
| { status: 'ERROR', error: string };
```
## 4. 页面设计与布局 (ASCII Art)
**设计原则**: 严格保留现有 Next.js 版本的 UI 布局、风格和功能流程。
### 4.1 整体布局 (Shell)
保留现有的顶部导航栏设计 (`RootLayout`)。历史报告改为下拉菜单。
```
+---------------------------------------------------------------+
| FA Platform |
| [首页] [历史报告 v] [文档] [配置] |
+-----------+---------------------------------------------------+
| | (Dropdown Menu) |
| | 2025-11-22 10:00: 600519.SS (Success) |
| | 2025-11-21 14:30: AAPL (Failed) |
| | ... |
| +---------------------------------------------------+
| |
| <Outlet /> (主内容区域) |
| |
+---------------------------------------------------------------+
```
### 4.2 仪表盘 (Dashboard / Home)
入口页面,用于发起新的分析。
```
+---------------------------------------------------------------+
| 基本面分析报告 |
| 输入股票代码和市场,生成综合分析报告。 |
+---------------------------------------------------------------+
| |
| [ Card: Start Analysis ] |
| +---------------------------------------------------------+ |
| | 股票代码: [ 600519 ] | |
| | 交易市场: [ 中国 v ] | |
| | | |
| | [ BUTTON: 生成报告 ] | |
| +---------------------------------------------------------+ |
| |
+---------------------------------------------------------------+
```
### 4.3 报告详情页 (Report View)
核心页面,负责展示实时工作流状态和分析结果。
**关键改进**: Tabs 上需有直观的状态提示 (Spinner/Check/X)。
```
+---------------------------------------------------------------+
| [Card: Header] |
| 600519.SS Market: CN [Badge: Ready/Analyzing] |
| [Template Select] [Start Btn] |
+---------------------------------------------------------------+
| |
| [Tabs List] |
| +---------------------------------------------------------+ |
| | [DAG View] (Always First) | |
| | [Stock Chart] | |
| | [Fundamental Data] [Spinner] (Fetching...) | |
| | [Analysis: Basic] [Check] | |
| | [Analysis: Value] [Spinner] | |
| | [Analysis: Risk ] [Pending] | |
| +---------------------------------------------------------+ |
| |
| (Tab Content Area) |
| |
| ----------------------------------------------------------- |
| Request ID: ... | Time: 12.5s | Tokens: 1500 |
+---------------------------------------------------------------+
```
#### 4.3.1 DAG 视图 (Workflow Graph)
使用 ReactFlow 渲染。
* **节点**: 代表任务 (Fetch Data, Analysis Modules)。
* **连线**: 代表依赖关系。
* **状态**: 节点颜色随状态变化 (灰=Pending, 蓝=Running, 绿=Completed, 红=Failed)。
* **交互**: 点击节点自动切换 Tabs 到对应的详情页。
```
+---------------------------------------------------------------+
| [ DAG View ] |
| |
| [Fetch: Tushare] ---> [Fundamental Data] |
| (Running) (Pending) |
| | |
| [Fetch: Finnhub] --------------+ |
| (Done) | |
| v |
| [Analysis: Basic] |
| (Pending) |
| |
+---------------------------------------------------------------+
```
#### 4.3.2 Fundamental Data Tab
展示多个数据源的并行获取状态。
```
+---------------------------------------------------------------+
| [ Fundamental Data ] |
| |
| [ Card: Tushare Provider ] |
| Status: [Spinner] Running (3.2s) |
| Logs: |
| > Connecting to api.tushare.pro... |
| > Fetching income statement... |
| |
| [ Card: Finnhub Provider ] |
| Status: [Check] Completed |
| Result: Fetched 3 years of data. |
| |
| [ Card: AlphaVantage Provider ] |
| Status: [X] Failed |
| Error: Rate limit exceeded. |
| |
+---------------------------------------------------------------+
```
#### 4.3.3 Analysis Module Tab
展示 LLM 分析的流式输出。
```
+---------------------------------------------------------------+
| [ Analysis: Basic Analysis ] |
| |
| Status: [Spinner] Generating Report... |
| Model: gpt-4o |
| |
| (Markdown Content Streaming Area) |
| # Basic Financial Analysis |
| Based on the income statement... [Cursor] |
| |
+---------------------------------------------------------------+
```
### 4.4 配置页面 (Config)
#### 4.4.1 AI Providers 配置 (Tab 1)
**设计逻辑**:
1. **Provider 维度**: 允许配置多个 Provider (如 OpenAI, Anthropic, LocalLLM)。
2. **Model 维度**: 每个 Provider 下包含多个 Model。
3. **交互流程**: 添加 Provider -> 输入 Key/BaseURL -> 点击 "刷新模型列表" (Fetch List) -> 从下拉框选择或手动添加模型 -> 点击 "测试" (Test) -> 保存。
```
+---------------------------------------------------------------+
| [ Tabs: AI Provider | 数据源配置 | 分析模板 | 系统 ] |
+---------------------------------------------------------------+
| |
| [ Button: + 添加 AI Provider ] |
| |
| [ Card: OpenAI (Official) ] [Delete] |
| +---------------------------------------------------------+ |
| | Base URL: [ https://api.openai.com/v1 ] | |
| | API Key: [ ************************ ] [Eye Icon] | |
| | | |
| | [ Button: 刷新模型列表 (Refresh List) ] | |
| | | |
| | **模型列表 (Models)**: | |
| | +-----------------------------------------------------+ | |
| | | gpt-4o [Status: Active] [Test] [Remove] | | |
| | | gpt-3.5-turbo [Status: Active] [Test] [Remove] | | |
| | +-----------------------------------------------------+ | |
| | | |
| | [ Input: 添加模型 (支持搜索/补全) ] [Button: Add] | |
| | (输入 "claude" 自动提示 "claude-3-sonnet", etc.) | |
| +---------------------------------------------------------+ |
| |
| [ Card: Local LLM (Ollama) ] |
| ... |
| |
+---------------------------------------------------------------+
```
#### 4.4.2 数据源配置 (Data Sources) (Tab 2)
**设计逻辑**:
1. **后端驱动 (Backend Driven)**: 页面不硬编码有哪些数据源。
2. **查询流程**: 前端请求 `GET /configs/data_sources/schema` (或类似接口),后端返回可用的 Provider 列表及其所需配置项 (Schema)。
3. **动态渲染**: 根据后端返回的 Schema 渲染表单 (如 API Key 输入框, URL 输入框)。
4. **测试**: 如果后端支持 `Test` 接口,显示测试按钮。
```
+---------------------------------------------------------------+
| [ Tabs: AI Provider | 数据源配置 | 分析模板 | 系统 ] |
+---------------------------------------------------------------+
| |
| (Dynamically Rendered from Backend Config) |
| |
| [ Card: Tushare Pro ] (Enabled [x]) |
| +---------------------------------------------------------+ |
| | API Token: [ ******************** ] | |
| | Endpoint: [ http://api.tushare.pro ] | |
| | [Button: Test Connection] -> (Result: Success/Fail) | |
| +---------------------------------------------------------+ |
| |
| [ Card: AlphaVantage ] (Enabled [ ]) |
| +---------------------------------------------------------+ |
| | API Key: [ ] | |
| | [Button: Test Connection] | |
| +---------------------------------------------------------+ |
| |
+---------------------------------------------------------------+
```
#### 4.4.3 分析模板配置 (Analysis Templates) (Tab 3)
**设计逻辑**:
1. **模板 (Template)**: 分析报告的骨架,包含多个模块。
2. **模块 (Module)**: 具体的分析任务 (如 "基本面概览", "风险评估")。
3. **N*M 模型选择**: 每个模块可以指定特定的 AI 模型。
* **来源**: 聚合所有 AI Providers 中已启用的模型。
* **交互**: 下拉搜索框 (Combobox),输入关键词 (如 "gpt") 筛选出 `{provider: "openai", model: "gpt-4"}`
4. **依赖管理 (DAG)**: 指定模块依赖关系 (如 "风险评估" 依赖 "基本面概览")。
```
+---------------------------------------------------------------+
| [ Tabs: AI Provider | 数据源配置 | 分析模板 | 系统 ] |
+---------------------------------------------------------------+
| |
| 当前模板: [ 快速分析模板 v ] [+ 新建模板] [删除] |
| |
| **分析模块 (Modules)**: |
| |
| [ Card: 模块 1 - 基本面概览 ] |
| +---------------------------------------------------------+ |
| | ID: basic_analysis | |
| | 依赖 (Dependencies): [ 无 v ] | |
| | | |
| | **模型选择 (Model)**: | |
| | [ Combobox: gpt-4o (OpenAI) v ] | |
| | (数据来源: 聚合自 AI Provider Tab 的所有模型) | |
| | | |
| | **提示词 (Prompt)**: | |
| | [ Textarea: Analyze the financial data... ] | |
| +---------------------------------------------------------+ |
| |
| [ Card: 模块 2 - 深度风险评估 ] |
| +---------------------------------------------------------+ |
| | ID: risk_eval | |
| | 依赖 (Dependencies): [ 基本面概览 (basic_analysis) x ] | |
| | Model: [ claude-3-opus (Anthropic) v ] | |
| | ... | |
| +---------------------------------------------------------+ |
| |
| [ Button: + 添加分析模块 ] [ Button: 保存配置 ] |
+---------------------------------------------------------------+
```
#### 4.4.4 系统状态 (System) (Tab 4)
**设计逻辑**:
1. **Status Check**: 简单的健康看板。
2. **Modules**: 列出所有微服务/组件的状态 (Running, Degraded, Down)。
```
+---------------------------------------------------------------+
| [ Tabs: AI Provider | 数据源配置 | 分析模板 | 系统 ] |
+---------------------------------------------------------------+
| |
| **系统健康状态 (System Health)** |
| |
| [ API Gateway ] [ Badge: Healthy (Green) ] |
| [ Workflow Orchestrator] [ Badge: Healthy (Green) ] |
| [ Data Persistence ] [ Badge: Healthy (Green) ] |
| [ Report Generator ] [ Badge: Degraded (Yellow) ] |
| |
| **服务详情**: |
| - Database Connection: OK |
| - NATS Connection: OK |
| - Redis Cache: OK |
| |
+---------------------------------------------------------------+
```
## 5. 目录结构 (Proposed)
```
frontend/
├── public/
├── src/
│ ├── api/ # Axios instances, Zod schemas
│ ├── assets/
│ ├── components/ # Shared UI components
│ │ ├── ui/ # shadcn/ui primitives (迁移自原项目)
│ │ ├── layout/ # Shell, Header
│ │ ├── workflow/ # DAG Graph, Status Badges
│ │ └── business/ # 业务组件
│ │ ├── StockChart.tsx
│ │ ├── FinancialTable.tsx
│ │ └── ModelSelector.tsx # 复用的模型选择器
│ ├── pages/ # 路由页面组件
│ │ ├── Dashboard.tsx
│ │ ├── Report.tsx
│ │ └── config/ # 配置相关页面拆分
│ │ ├── index.tsx # 配置页 Layout
│ │ ├── AIProviderTab.tsx
│ │ ├── DataSourceTab.tsx
│ │ └── TemplateTab.tsx
│ ├── hooks/ # Global hooks
│ ├── lib/ # Utils (cn, formatters)
│ ├── stores/ # Zustand stores
│ ├── types/ # Global types (if not in schema)
│ ├── App.tsx # Router Setup
│ └── main.tsx # Entry
├── index.html
├── package.json
└── vite.config.ts
```
## 6. 迁移步骤
1. **归档**: 将现有 `frontend` 移动到 `frontend/archive/v2_nextjs`
2. **初始化**: 在 `frontend` 创建新的 Vite 项目。
3. **安装依赖**: Tailwind, Shadcn, Axios, Zustand, React Router, Lucide, ReactFlow。
4. **移植 UI**: 从归档中复制 `components/ui` (shadcn)。
5. **移植逻辑**:
* 重写 `useWorkflow` hook使用新的 Store 模式。
* 实现 DAG 可视化组件。
* 实现配置页面的“添加+补全”交互。
6. **验证**: 测试与后端的 SSE 连接和 DAG 状态同步。
## 7. 执行阶段 (Next Steps)
1. 归档现有代码。
2. 初始化 Vite + React 项目。
3. 配置 Tailwind + Shadcn 环境。
4. 搭建基础 Layout (Shell)。

View File

@ -0,0 +1,75 @@
# 后端改造需求清单 (配合前端重构)
日期: 2025-11-22
状态: 待执行
为了支持新的 "Puppet Architecture" 前端设计,后端需要进行以下适配性改造。
## 1. API 规范与类型生成 (OpenAPI)
**目标**: 支持前端通过脚本自动从后端生成 TypeScript 类型定义 (Zod Schemas),确保前后端类型严格一致,实现 "Single Source of Truth"。
* **任务**: 在 `api-gateway` 中集成 `utoipa``utoipa-swagger-ui`
* **要求**:
* 暴露 `GET /api-docs/openapi.json` 路由。
* 确保所有核心 Struct (特别是 `WorkflowEvent`, `TaskStatus`, `LlmProvider`, `AnalysisTemplateSet`) 都在 Schema 中正确导出。
* 对于 Enum 类型,确保导出为带 `type` 字段的 Discriminated Union 格式(如果可能),或者前端通过 generator 处理。
## 2. 动态数据源 Schema 接口
**目标**: 实现数据源的插件化和动态发现。前端不应硬编码支持哪些数据源,也不应知道每个数据源需要哪些配置字段。
* **背景**: 未来数据源将作为微服务动态插拔。
* **任务**: 新增接口 `GET /v1/configs/data_sources/schema`
* **响应结构示例**:
```json
{
"providers": [
{
"id": "tushare",
"name": "Tushare Pro",
"description": "Official Tushare Data Provider",
"fields": [
{
"key": "api_token",
"label": "API Token",
"type": "password", // text, password, select, boolean
"required": true,
"placeholder": "Enter your token...",
"description": "Get it from https://tushare.pro"
},
{
"key": "api_url",
"label": "API Endpoint",
"type": "text",
"default": "http://api.tushare.pro",
"required": false
}
]
}
]
}
```
* **逻辑**: 该接口应聚合当前注册的所有 Data Provider 服务的配置元数据。
## 3. 任务进度 (Progress) 字段支持
**目标**: 支持在 UI 上展示细粒度的任务进度条 (0-100%),而不仅仅是状态切换。
* **背景**: Data Provider 抓取大量数据或 Deep Research 分析时,需要反馈进度。
* **任务**:
1. **修改 Contract**: 在 `common-contracts``WorkflowEvent::TaskStateChanged` 中增加 `progress` 字段。
```rust
pub struct TaskStateChanged {
// ... existing fields
pub progress: Option<u8>, // 0-100
}
```
2. **Orchestrator 适配**: 在处理任务更新时,支持透传进度值。
3. **Provider 适配**: 长耗时任务(如 `fetch_history`)应定期发送带有进度的状态更新。
4. **兼容性**: 对于不支持进度的瞬时任务,开始时为 0完成时为 100。
## 4. 确认项 (无需代码变更,仅作备忘)
* **Logs 处理**: `TaskStateChanged` 中的 `message` 字段将被视为增量日志。前端收到事件时,会将非空的 `message` 追加 (Append) 到该任务的日志列表中。
* **SSE 稳定性**: 确保 `workflow_events_stream` 在连接建立时立即发送 `WorkflowStateSnapshot` (已实现),以处理前端重连场景。

57
frontend/.gitignore vendored
View File

@ -1,43 +1,24 @@
# 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
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
pnpm-debug.log*
lerna-debug.log*
# env files (can opt-in for committing if needed)
.env*
node_modules
dist
dist-ssr
*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

File diff suppressed because it is too large Load Diff

43
frontend/archive/v2_nextjs/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# 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
/src/generated/prisma

View File

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

View File

@ -0,0 +1,30 @@
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
// Explicitly set Turbopack root to this frontend directory to silence multi-lockfile warning
turbopack: {
root: __dirname,
},
// Increase server timeout for long-running AI requests
experimental: {
proxyTimeout: 300000, // 300 seconds (5 minutes)
},
// Optimize for Docker deployment only in production
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
async rewrites() {
const apiUrl = process.env.API_GATEWAY_URL || 'http://api-gateway:4000';
return [
{
source: '/api/:path*',
destination: `${apiUrl}/v1/:path*`,
},
];
},
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "NODE_NO_WARNINGS=1 next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.5.1",
"github-markdown-css": "^5.8.1",
"lucide-react": "^0.545.0",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.3.0",
"remark-gfm": "^4.0.1",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,43 @@
#!/bin/sh
set -euo pipefail
PROJECT_DIR="${PROJECT_DIR:-/workspace/frontend}"
LOCKFILE="${PROJECT_DIR}/package-lock.json"
NODE_MODULES_DIR="${PROJECT_DIR}/node_modules"
HASH_FILE="${NODE_MODULES_DIR}/.package-lock.hash"
DEV_COMMAND="${DEV_COMMAND:-npm run dev}"
cd "${PROJECT_DIR}"
calculate_lock_hash() {
sha256sum "${LOCKFILE}" | awk '{print $1}'
}
write_hash() {
calculate_lock_hash > "${HASH_FILE}"
}
install_dependencies() {
echo "[frontend] 安装/更新依赖..."
npm ci
write_hash
}
if [ ! -d "${NODE_MODULES_DIR}" ]; then
install_dependencies
elif [ ! -f "${HASH_FILE}" ]; then
install_dependencies
else
current_hash="$(calculate_lock_hash)"
installed_hash="$(cat "${HASH_FILE}" 2>/dev/null || true)"
if [ "${current_hash}" != "${installed_hash}" ]; then
echo "[frontend] package-lock.json 发生变化,重新安装依赖..."
install_dependencies
else
echo "[frontend] 依赖哈希一致,跳过 npm ci。"
fi
fi
exec ${DEV_COMMAND}

View File

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ requestId: string }> }
) {
const { requestId } = await params;
// Use container internal URL if available, otherwise fallback
const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://api-gateway:4000/v1';
const targetUrl = `${backendUrl}/workflow/events/${requestId}`;
console.log(`[API Route] Proxying SSE for request ${requestId} to ${targetUrl}`);
try {
const response = await fetch(targetUrl, {
headers: {
'Accept': 'text/event-stream',
},
cache: 'no-store',
});
if (!response.ok) {
console.error(`[API Route] Upstream error: ${response.status} ${response.statusText}`);
return NextResponse.json(
{ error: `Upstream error: ${response.status}` },
{ status: response.status }
);
}
if (!response.body) {
return NextResponse.json(
{ error: 'No response body from upstream' },
{ status: 502 }
);
}
const stream = response.body;
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Content-Encoding': 'none',
'X-Accel-Buffering': 'no', // For Nginx if present
},
});
} catch (error) {
console.error('[API Route] Proxy failed:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@ -329,7 +329,9 @@ export function AnalysisConfigTab() {
{isCreatingModule && (
<div className="space-y-4 p-4 border rounded-lg border-dashed">
<h3 className="text-lg font-semibold"> "{localTemplateSets[selectedTemplateId].name}" </h3>
<h3 className="text-lg font-semibold">
&ldquo;{localTemplateSets[selectedTemplateId].name}&rdquo;
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="new-module-id"> ID (, )</Label>

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TaskStatus } from '@/types/workflow';
import { AnalysisModuleConfig } from '@/types/index';
import { BrainCircuit, Terminal, Info } from 'lucide-react';
import { TaskInfo } from '@/hooks/useWorkflow';
interface AnalysisModulesViewProps {
taskStates: Record<string, TaskStatus>;
taskInfos: Record<string, TaskInfo>;
taskOutputs: Record<string, string>;
modulesConfig: Record<string, AnalysisModuleConfig>;
}
export function AnalysisModulesView({
taskStates,
taskInfos,
taskOutputs,
modulesConfig
}: AnalysisModulesViewProps) {
console.log('[AnalysisModulesView] Render. Config keys:', Object.keys(modulesConfig));
console.log('[AnalysisModulesView] Task States:', taskStates);
// Identify analysis tasks based on the template config
// We assume task IDs in the DAG correspond to module IDs or follow a pattern
// For now, let's try to match tasks that are NOT fetch tasks
// If we have config, use it to drive tabs
const moduleIds = Object.keys(modulesConfig);
const [activeModuleId, setActiveModuleId] = useState<string>(moduleIds[0] || '');
useEffect(() => {
// If no active module and we have modules, select first
if (!activeModuleId && moduleIds.length > 0) {
setActiveModuleId(moduleIds[0]);
}
}, [moduleIds, activeModuleId]);
if (moduleIds.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-[300px] border-dashed border-2 rounded-lg text-muted-foreground">
<BrainCircuit className="w-10 h-10 mb-2 opacity-50" />
<p>No analysis modules defined in this template.</p>
</div>
);
}
return (
<div className="space-y-4">
<Tabs value={activeModuleId} onValueChange={setActiveModuleId} className="w-full">
<div className="overflow-x-auto pb-2">
<TabsList className="w-full justify-start h-auto p-1 bg-transparent gap-2">
{moduleIds.map(moduleId => {
const config = modulesConfig[moduleId];
// Task ID might match module ID directly or be prefixed
// We need to check multiple patterns because the backend DAG might be simplified (e.g., single "analysis:report" task)
// or use "analysis:{moduleId}" format.
let taskId = moduleId;
let status = taskStates[taskId];
if (!status) {
// Try prefix format
taskId = `analysis:${moduleId}`;
status = taskStates[taskId];
}
if (!status) {
// Try fallback to generic analysis task if specific one is missing
// This handles the case where backend collapses all analysis into one task
taskId = 'analysis:report';
status = taskStates[taskId];
}
// Default to pending if still not found
status = status || 'Pending';
return (
<TabsTrigger
key={moduleId}
value={moduleId}
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground px-4 py-2 rounded-md border bg-card hover:bg-accent/50 transition-all"
>
<div className="flex items-center gap-2">
<span>{config.name}</span>
<StatusDot status={status} />
</div>
</TabsTrigger>
);
})}
</TabsList>
</div>
{moduleIds.map(moduleId => {
// Resolve task ID and Status using the same logic as tabs
let taskId = moduleId;
let status = taskStates[taskId];
let output = taskOutputs[taskId];
let info = taskInfos[taskId];
if (!status) {
taskId = `analysis:${moduleId}`;
status = taskStates[taskId];
output = taskOutputs[taskId];
info = taskInfos[taskId];
}
if (!status) {
// Fallback for status
const genericId = 'analysis:report';
status = taskStates[genericId];
info = taskInfos[genericId];
// Note: We might not want to show generic output for specific module tab,
// but if it's the only output we have, maybe?
// Usually 'analysis:report' output might be the full report or a summary.
// Let's check if we have output for the specific module ID first in taskOutputs
// regardless of status.
if (!output) output = taskOutputs[genericId];
}
status = status || 'Pending';
output = output || '';
const config = modulesConfig[moduleId];
return (
<TabsContent key={moduleId} value={moduleId} className="mt-0">
<Card className="h-[600px] flex flex-col">
<CardHeader className="py-4 border-b bg-muted/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-lg">{config.name}</CardTitle>
<Badge variant="outline" className="font-mono text-xs">
{config.model_id}
</Badge>
</div>
<StatusBadge status={status} message={info?.message} />
</div>
</CardHeader>
<CardContent className="flex-1 p-0 min-h-0 relative">
<ScrollArea className="h-full p-6">
{output ? (
<div className="prose dark:prose-invert max-w-none pb-10">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{output}
</ReactMarkdown>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2 opacity-50">
<Terminal className="w-8 h-8" />
<p>
{status === 'Running' ? 'Generating analysis...' :
status === 'Skipped' ? 'Module skipped.' :
status === 'Failed' ? 'Module failed.' :
'Waiting for input...'}
</p>
{info?.message && (status === 'Skipped' || status === 'Failed') && (
<p className="text-sm text-red-500 max-w-md text-center mt-2">
Reason: {info.message}
</p>
)}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
</div>
);
}
function StatusDot({ status }: { status: TaskStatus }) {
let colorClass = "bg-muted";
if (status === 'Completed') colorClass = "bg-green-500";
if (status === 'Failed') colorClass = "bg-red-500";
if (status === 'Running') colorClass = "bg-blue-500 animate-pulse";
if (status === 'Scheduled') colorClass = "bg-yellow-500";
if (status === 'Skipped') colorClass = "bg-gray-400";
return <div className={`w-2 h-2 rounded-full ${colorClass}`} />;
}
function StatusBadge({ status, message }: { status: TaskStatus, message?: string }) {
let badge = <Badge variant="outline">Pending</Badge>;
switch (status) {
case 'Completed':
badge = <Badge variant="outline" className="text-green-600 border-green-200 bg-green-50">Completed</Badge>;
break;
case 'Failed':
badge = <Badge variant="destructive">Failed</Badge>;
break;
case 'Running':
badge = <Badge variant="secondary" className="text-blue-600 bg-blue-50 animate-pulse">Generating...</Badge>;
break;
case 'Scheduled':
badge = <Badge variant="outline" className="text-yellow-600 border-yellow-200 bg-yellow-50">Scheduled</Badge>;
break;
case 'Skipped':
badge = <Badge variant="outline" className="text-gray-500">Skipped</Badge>;
break;
default:
badge = <Badge variant="outline">Pending</Badge>;
}
if (message) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-help">
{badge}
<Info className="w-4 h-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return badge;
}

View File

@ -2,14 +2,20 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Info } from 'lucide-react';
import { TaskStatus } from '@/types/workflow';
import { TaskInfo } from '@/hooks/useWorkflow';
interface FundamentalDataViewProps {
taskStates: Record<string, TaskStatus>;
taskInfos: Record<string, TaskInfo>;
taskOutputs: Record<string, string>;
}
export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalDataViewProps) {
export function FundamentalDataView({ taskStates, taskInfos, taskOutputs }: FundamentalDataViewProps) {
console.log('[FundamentalDataView] Render. States:', Object.keys(taskStates).length, taskStates);
// Filter tasks that look like data fetching tasks
const dataTasks = Object.keys(taskStates).filter(taskId =>
taskId.startsWith('fetch:') // Standardized task ID format: "fetch:provider_id"
@ -28,6 +34,8 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
{dataTasks.map(taskId => {
const status = taskStates[taskId];
const output = taskOutputs[taskId];
const info = taskInfos[taskId];
// Dynamic name resolution: extract provider ID from task ID (e.g., "fetch:tushare" -> "Tushare")
const providerId = taskId.replace('fetch:', '');
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
@ -38,17 +46,22 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
<CardTitle className="text-lg font-medium truncate" title={providerName}>
{providerName}
</CardTitle>
<StatusBadge status={status} />
<StatusBadge status={status} message={info?.message} />
</CardHeader>
<CardContent className="flex-1 min-h-0 pt-2">
<ScrollArea className="h-full w-full border rounded-md bg-muted/5 p-4">
{output ? (
{status === 'Failed' && info?.message ? (
<div className="text-sm text-red-500 whitespace-pre-wrap break-words">
<p className="font-semibold mb-1">Error:</p>
{info.message}
</div>
) : output ? (
<pre className="text-xs font-mono whitespace-pre-wrap break-words text-foreground/80">
{tryFormatJson(output)}
</pre>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm italic">
{status === 'pending' || status === 'running' ? 'Waiting for data...' : 'No data returned'}
{status === 'Pending' || status === 'Running' || status === 'Scheduled' ? 'Waiting for data...' : 'No data returned'}
</div>
)}
</ScrollArea>
@ -60,17 +73,48 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
);
}
function StatusBadge({ status }: { status: TaskStatus }) {
function StatusBadge({ status, message }: { status: TaskStatus, message?: string }) {
let badge = <Badge variant="outline">Pending</Badge>;
switch (status) {
case 'completed':
return <Badge variant="default" className="bg-green-600">Success</Badge>;
case 'failed':
return <Badge variant="destructive">Failed</Badge>;
case 'running':
return <Badge variant="secondary" className="animate-pulse text-blue-500">Fetching</Badge>;
case 'Completed':
badge = <Badge variant="default" className="bg-green-600 hover:bg-green-700">Success</Badge>;
break;
case 'Failed':
badge = <Badge variant="destructive">Failed</Badge>;
break;
case 'Running':
badge = <Badge variant="secondary" className="animate-pulse text-blue-500">Fetching</Badge>;
break;
case 'Scheduled':
badge = <Badge variant="outline" className="text-yellow-600 border-yellow-200 bg-yellow-50">Scheduled</Badge>;
break;
case 'Skipped':
badge = <Badge variant="outline" className="text-gray-500">Skipped</Badge>;
break;
default:
return <Badge variant="outline">Pending</Badge>;
badge = <Badge variant="outline">{status}</Badge>;
}
if (message) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-help">
{badge}
<Info className="w-4 h-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return badge;
}
function tryFormatJson(str: string): string {
@ -87,4 +131,3 @@ function tryFormatJson(str: string): string {
return str;
}
}

View File

@ -56,21 +56,30 @@ export function WorkflowReportLayout({
.catch(err => console.error('Failed to load templates:', err));
}, [selectedTemplateId]);
// Auto switch to analysis tab when workflow starts
// Auto switch to analysis tab when workflow starts - DISABLED
// User prefers to see Data Fetching first or stay on current tab
/*
useEffect(() => {
if (workflow.status === 'connecting' || workflow.status === 'connected') {
setActiveTab("analysis");
}
}, [workflow.status]);
*/
const handleStart = async () => {
if (!selectedTemplateId) return;
console.log('[WorkflowReportLayout] Start clicked for symbol:', symbol, 'Template:', selectedTemplateId);
if (!selectedTemplateId) {
console.warn('[WorkflowReportLayout] No template selected');
return;
}
console.log('[WorkflowReportLayout] Calling startWorkflow...');
const response = await workflow.startWorkflow({
symbol,
market: initialMarket,
template_id: selectedTemplateId
});
console.log('[WorkflowReportLayout] startWorkflow returned:', response);
// Handle Symbol Normalization Redirection
if (response && response.symbol && response.symbol !== symbol) {
@ -171,6 +180,7 @@ export function WorkflowReportLayout({
<TabsContent value="data" className="mt-4">
<FundamentalDataView
taskStates={workflow.taskStates}
taskInfos={workflow.taskInfos}
taskOutputs={workflow.taskOutputs}
/>
</TabsContent>
@ -180,6 +190,7 @@ export function WorkflowReportLayout({
{workflow.requestId ? (
<AnalysisModulesView
taskStates={workflow.taskStates}
taskInfos={workflow.taskInfos}
taskOutputs={workflow.taskOutputs}
modulesConfig={dynamicModules}
/>
@ -272,4 +283,3 @@ function EmptyState({ onStart, message }: { onStart: () => void, message: string
}
// Removed FinalReportView as it is now superseded by AnalysisModulesView

View File

@ -120,7 +120,11 @@ export function TradingViewWidget({
// TradingView 的 embed 脚本会在内部创建 iframe
// 如果容器正在被卸载,或者 iframe 尚未完全准备好,可能会触发该错误
// 我们只是 append script实际的 iframe 是由 TradingView 脚本注入的
try {
container.appendChild(script);
} catch (appendError) {
console.warn('[TradingView] Script append failed:', appendError);
}
}
} catch (e) {
// 忽略偶发性 contentWindow 不可用的报错

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Dialog> & {
title?: string
description?: string
}) {
return (
<CommandPrimitive.Dialog
data-slot="command-dialog"
{...props}
>
{children}
</CommandPrimitive.Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex items-center border-b px-3" data-slot="command-input-wrapper">
<SearchIcon className="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,48 @@
import React from 'react';
type BaseProps = {
children?: React.ReactNode;
className?: string;
};
type DialogProps = BaseProps & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const Dialog: React.FC<DialogProps> = ({ children, open, onOpenChange }) => {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative bg-background p-6 rounded-lg shadow-lg max-w-lg w-full">
{children}
<button
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700"
onClick={() => onOpenChange?.(false)}
>
</button>
</div>
</div>
);
};
export const DialogContent: React.FC<BaseProps> = ({ children, className }) => {
return <div className={className}>{children}</div>;
};
export const DialogHeader: React.FC<BaseProps> = ({ children, className }) => {
return <div className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}>{children}</div>;
};
export const DialogTitle: React.FC<BaseProps> = ({ children, className }) => {
return <h3 className={`text-lg font-semibold leading-none tracking-tight ${className}`}>{children}</h3>;
};
export const DialogDescription: React.FC<BaseProps> = ({ children, className }) => {
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
};
export const DialogFooter: React.FC<BaseProps> = ({ children, className }) => {
return <div className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}>{children}</div>;
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

View File

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

View File

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

View File

@ -0,0 +1,49 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
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 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

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

View File

@ -0,0 +1,17 @@
import React from 'react';
type ScrollAreaProps = {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
};
export const ScrollArea: React.FC<ScrollAreaProps> = ({ children, className, style }) => {
return (
<div className={className} style={{ overflow: 'auto', ...style }}>
{children}
</div>
);
};

View File

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

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View File

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

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

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

View File

@ -0,0 +1,24 @@
import React from 'react';
type SwitchProps = {
id?: string;
name?: string;
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
className?: string;
};
export const Switch: React.FC<SwitchProps> = ({ id, name, checked, onCheckedChange, className }) => {
return (
<input
id={id}
name={name}
type="checkbox"
className={className}
checked={!!checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
/>
);
};

View File

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

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { WorkflowDag, TaskNode, TaskStatus, TaskType } from '@/types/workflow';
import { TaskOutputViewer } from './TaskOutputViewer';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import {
CheckCircle2,
Circle,
Clock,
AlertCircle,
Loader2,
SkipForward,
Database,
FileText,
BrainCircuit
} from 'lucide-react';
interface WorkflowVisualizerProps {
dag: WorkflowDag;
taskStates: Record<string, TaskStatus>;
taskOutputs: Record<string, string>;
className?: string;
}
const TYPE_ORDER: Record<TaskType, number> = {
'DataFetch': 1,
'DataProcessing': 2,
'Analysis': 3
};
const TYPE_ICONS: Record<TaskType, React.ReactNode> = {
'DataFetch': <Database className="w-4 h-4" />,
'DataProcessing': <FileText className="w-4 h-4" />,
'Analysis': <BrainCircuit className="w-4 h-4" />
};
export function WorkflowVisualizer({
dag,
taskStates,
taskOutputs,
className
}: WorkflowVisualizerProps) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// Sort nodes by type then name
const sortedNodes = [...dag.nodes].sort((a, b) => {
const typeScoreA = TYPE_ORDER[a.type] || 99;
const typeScoreB = TYPE_ORDER[b.type] || 99;
if (typeScoreA !== typeScoreB) return typeScoreA - typeScoreB;
return a.name.localeCompare(b.name);
});
// Auto-select first node or running node if none selected
useEffect(() => {
if (!selectedTaskId && sortedNodes.length > 0) {
// Try to find a running node, or the first one
const runningNode = sortedNodes.find(n => taskStates[n.id] === 'Running');
setSelectedTaskId(runningNode ? runningNode.id : sortedNodes[0].id);
}
}, [dag, taskStates, selectedTaskId, sortedNodes]);
const selectedNode = dag.nodes.find(n => n.id === selectedTaskId);
return (
<div className={cn("flex h-[600px] border rounded-lg overflow-hidden bg-background", className)}>
{/* Left Sidebar: Task List */}
<div className="w-1/3 min-w-[250px] border-r bg-muted/10 flex flex-col">
<div className="p-4 border-b bg-muted/20">
<h3 className="font-semibold text-sm text-foreground">Workflow Tasks</h3>
<p className="text-xs text-muted-foreground mt-1">
{dag.nodes.length} steps in pipeline
</p>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{sortedNodes.map(node => (
<TaskListItem
key={node.id}
node={node}
status={taskStates[node.id] || node.initial_status}
isSelected={selectedTaskId === node.id}
onClick={() => setSelectedTaskId(node.id)}
/>
))}
</div>
</ScrollArea>
</div>
{/* Right Content: Output Viewer */}
<div className="flex-1 min-w-0 bg-card">
{selectedNode ? (
<TaskOutputViewer
taskId={selectedNode.id}
taskName={selectedNode.name}
taskType={selectedNode.type}
status={taskStates[selectedNode.id] || selectedNode.initial_status}
content={taskOutputs[selectedNode.id] || ''}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
Select a task to view details
</div>
)}
</div>
</div>
);
}
function TaskListItem({
node,
status,
isSelected,
onClick
}: {
node: TaskNode;
status: TaskStatus;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 p-3 text-sm text-left rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
isSelected ? "bg-accent text-accent-foreground shadow-sm" : "text-muted-foreground"
)}
>
<StatusIcon status={status} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{node.name}</div>
<div className="flex items-center gap-1 text-xs opacity-70 mt-0.5">
{TYPE_ICONS[node.type]}
<span>{node.type}</span>
</div>
</div>
</button>
);
}
function StatusIcon({ status }: { status: TaskStatus }) {
switch (status) {
case 'Completed':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'Failed':
return <AlertCircle className="w-4 h-4 text-red-500" />;
case 'Running':
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
case 'Scheduled':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'Skipped':
return <SkipForward className="w-4 h-4 text-gray-400" />;
default: // Pending
return <Circle className="w-4 h-4 text-gray-300" />;
}
}

View File

@ -222,11 +222,14 @@ export function useRealtimeQuote(
export function useConfig() {
const { setConfig, setError, setLoading } = useConfigStore();
const { data, error, isLoading } = useSWR<SystemConfig>('/api/config', fetcher);
const { data, error, isLoading } = useSWR<SystemConfig>('/api/config', fetcher, {
revalidateOnFocus: false,
});
useEffect(() => {
setLoading(Boolean(isLoading));
if (error) {
setError(error.message || '加载配置失败');
setError('加载配置失败');
} else if (data) {
setConfig(data);
}
@ -235,24 +238,8 @@ export function useConfig() {
}
export async function updateConfig(payload: Partial<SystemConfig>) {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const updated: SystemConfig = await res.json();
// 同步到 store
try {
const { setConfig } = useConfigStore.getState();
setConfig(updated);
} catch (_) {
// ignore
}
return updated;
// Deprecated
throw new Error("updateConfig is deprecated. Use specific update functions.");
}
export async function testConfig(type: string, data: any) {
@ -273,7 +260,7 @@ export async function testConfig(type: string, data: any) {
try {
const err = JSON.parse(text);
// 优先从标准字段中提取错误信息;同时分离 details
let message: string = err?.error || err?.message || '';
const message: string = err?.error || err?.message || '';
let detailsStr = '';
if (err?.details !== undefined) {
if (typeof err.details === 'string') {

View File

@ -4,17 +4,27 @@ import {
WorkflowDag,
TaskStatus,
StartWorkflowRequest,
StartWorkflowResponse
StartWorkflowResponse,
TaskType
} from '@/types/workflow';
export type WorkflowConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
export interface TaskInfo {
taskId: string;
type?: TaskType;
status: TaskStatus;
message?: string;
lastUpdate: number;
}
interface UseWorkflowReturn {
// State
status: WorkflowConnectionStatus;
requestId: string | null;
dag: WorkflowDag | null;
taskStates: Record<string, TaskStatus>;
taskInfos: Record<string, TaskInfo>; // Added for rich metadata
taskOutputs: Record<string, string>; // Accumulates streaming content
error: string | null;
finalResult: any | null;
@ -31,6 +41,7 @@ export function useWorkflow(): UseWorkflowReturn {
const [requestId, setRequestId] = useState<string | null>(null);
const [dag, setDag] = useState<WorkflowDag | null>(null);
const [taskStates, setTaskStates] = useState<Record<string, TaskStatus>>({});
const [taskInfos, setTaskInfos] = useState<Record<string, TaskInfo>>({});
const [taskOutputs, setTaskOutputs] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [finalResult, setFinalResult] = useState<any | null>(null);
@ -50,25 +61,48 @@ export function useWorkflow(): UseWorkflowReturn {
}, []);
const handleEvent = useCallback((eventData: WorkflowEvent) => {
console.log('[useWorkflow] Handling event type:', eventData.type);
switch (eventData.type) {
case 'WorkflowStarted':
console.log('[useWorkflow] WorkflowStarted. Nodes:', eventData.payload.task_graph.nodes.length);
setDag(eventData.payload.task_graph);
// Initialize states based on graph
const initialStates: Record<string, TaskStatus> = {};
const initialInfos: Record<string, TaskInfo> = {};
eventData.payload.task_graph.nodes.forEach(node => {
initialStates[node.id] = node.initial_status;
initialInfos[node.id] = {
taskId: node.id,
type: node.type,
status: node.initial_status,
lastUpdate: eventData.payload.timestamp
};
});
setTaskStates(initialStates);
setTaskInfos(initialInfos);
break;
case 'TaskStateChanged':
const { task_id, status, message, timestamp, task_type } = eventData.payload;
console.log(`[useWorkflow] TaskStateChanged: ${task_id} -> ${status}`);
setTaskStates(prev => ({
...prev,
[eventData.payload.task_id]: eventData.payload.status
[task_id]: status
}));
setTaskInfos(prev => ({
...prev,
[task_id]: {
taskId: task_id,
type: task_type,
status: status,
message: message || undefined, // normalize null to undefined
lastUpdate: timestamp
}
}));
break;
case 'TaskStreamUpdate':
// console.log(`[useWorkflow] StreamUpdate for ${eventData.payload.task_id}, len: ${eventData.payload.content_delta.length}`);
setTaskOutputs(prev => ({
...prev,
[eventData.payload.task_id]: (prev[eventData.payload.task_id] || '') + eventData.payload.content_delta
@ -76,9 +110,23 @@ export function useWorkflow(): UseWorkflowReturn {
break;
case 'WorkflowStateSnapshot':
console.log('[useWorkflow] Snapshot received. Tasks:', Object.keys(eventData.payload.tasks_status).length);
// Restore full state
setDag(eventData.payload.task_graph);
setTaskStates(eventData.payload.tasks_status);
// Reconstruct basic infos from snapshot (Snapshot doesn't carry full history messages sadly,
// but we can at least sync status)
const syncedInfos: Record<string, TaskInfo> = {};
Object.entries(eventData.payload.tasks_status).forEach(([tid, stat]) => {
syncedInfos[tid] = {
taskId: tid,
status: stat,
lastUpdate: eventData.payload.timestamp
};
});
setTaskInfos(prev => ({...prev, ...syncedInfos})); // Merge to keep existing messages if we have them
// Restore outputs if present
const outputs: Record<string, string> = {};
Object.entries(eventData.payload.tasks_output).forEach(([k, v]) => {
@ -88,11 +136,13 @@ export function useWorkflow(): UseWorkflowReturn {
break;
case 'WorkflowCompleted':
console.log('[useWorkflow] Workflow Completed');
setFinalResult(eventData.payload.result_summary);
disconnect(); // Close connection on completion
break;
case 'WorkflowFailed':
console.error('[useWorkflow] Workflow Failed:', eventData.payload.reason);
setError(eventData.payload.reason);
// We might want to keep connected or disconnect depending on if retry is possible
// For now, treat fatal error as disconnect reason
@ -105,7 +155,9 @@ export function useWorkflow(): UseWorkflowReturn {
}, [disconnect]);
const connectToWorkflow = useCallback((id: string) => {
console.log('[useWorkflow] connectToWorkflow called for ID:', id);
if (eventSourceRef.current) {
console.log('[useWorkflow] Closing existing EventSource');
eventSourceRef.current.close();
}
@ -114,27 +166,36 @@ export function useWorkflow(): UseWorkflowReturn {
setError(null);
try {
const es = new EventSource(`/api/workflow/events/${id}`);
const url = `/api/workflow/events/${id}`;
console.log('[useWorkflow] Creating new EventSource:', url);
// IMPORTANT: Do NOT use Next.js rewrites for SSE. They buffer.
// We use a direct API Route Handler (app/api/workflow/events/[requestId]/route.ts)
// which explicitly disables buffering via headers.
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => {
es.onopen = (e) => {
console.log(`[useWorkflow] SSE onopen triggered. URL: ${url}`, e);
setStatus('connected');
};
es.onmessage = (event) => {
try {
console.log('[useWorkflow] Raw SSE Message:', event.data);
const data = JSON.parse(event.data) as WorkflowEvent;
console.log('[useWorkflow] Parsed Event:', data.type, data);
handleEvent(data);
} catch (e) {
console.error('Failed to parse workflow event:', e);
console.error('[useWorkflow] Failed to parse workflow event:', e, event.data);
}
};
es.onerror = (e) => {
console.error('Workflow SSE error:', e);
console.error('[useWorkflow] Workflow SSE error:', e);
// EventSource automatically retries, but we might want to handle it explicitly
// For now, let's assume if readyState is CLOSED, it's a fatal error
if (es.readyState === EventSource.CLOSED) {
console.log('[useWorkflow] SSE Closed permanently');
setStatus('error');
setError('Connection lost');
es.close();
@ -149,33 +210,41 @@ export function useWorkflow(): UseWorkflowReturn {
}, [handleEvent]);
const startWorkflow = useCallback(async (params: StartWorkflowRequest) => {
console.log('[useWorkflow] startWorkflow called with params:', params);
setStatus('connecting');
setError(null);
setDag(null);
setTaskStates({});
setTaskInfos({});
setTaskOutputs({});
setFinalResult(null);
try {
console.log('[useWorkflow] Sending POST /api/workflow/start...');
const res = await fetch('/api/workflow/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
console.log('[useWorkflow] POST response status:', res.status);
if (!res.ok) {
const errorBody = await res.json().catch(() => ({}));
console.error('[useWorkflow] Start failed:', errorBody);
throw new Error(errorBody.error || `HTTP ${res.status}`);
}
const data: StartWorkflowResponse = await res.json();
console.log('[useWorkflow] Workflow started successfully. Response:', data);
// Start listening
console.log('[useWorkflow] Initiating SSE connection for requestId:', data.request_id);
connectToWorkflow(data.request_id);
return data; // Return response so UI can handle symbol normalization redirection
} catch (e) {
console.error('[useWorkflow] Exception in startWorkflow:', e);
setStatus('error');
setError(e instanceof Error ? e.message : 'Failed to start workflow');
return undefined;
@ -196,6 +265,7 @@ export function useWorkflow(): UseWorkflowReturn {
requestId,
dag,
taskStates,
taskInfos,
taskOutputs,
error,
finalResult,

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,23 +1,25 @@
import { create } from 'zustand';
import type { AnalysisTemplateSets, DataSourcesConfig, LlmProvidersConfig } from '@/types';
// 根据设计文档定义配置项的类型
// 根据设计文档定义配置项的类型(兼容 Legacy / 新结构)
export interface DatabaseConfig {
url: string;
url?: string | null;
}
export interface NewApiConfig {
api_key: string;
base_url?: string;
}
export interface DataSourceConfig {
[key: string]: { api_key: string };
api_key?: string | null;
base_url?: string | null;
provider_id?: string | null;
provider_name?: string | null;
model_count?: number;
}
export interface SystemConfig {
database: DatabaseConfig;
new_api: NewApiConfig;
data_sources: DataSourceConfig;
database?: DatabaseConfig | null;
new_api?: NewApiConfig | null;
data_sources?: DataSourcesConfig;
llm_providers?: LlmProvidersConfig;
analysis_template_sets?: AnalysisTemplateSets;
}
interface ConfigState {

View File

@ -0,0 +1,119 @@
/**
* Workflow Types Definition
* Corresponds to backend Rust types in `common-contracts/src/messages.rs`
*
*
* 1.
* 2.
*/
// ============================================================================
// Enums
// ============================================================================
export type TaskType = 'DataFetch' | 'DataProcessing' | 'Analysis';
export type TaskStatus =
| 'Pending' // 等待依赖
| 'Scheduled' // 依赖满足,已下发给 Worker
| 'Running' // Worker 正在执行
| 'Completed' // 执行成功
| 'Failed' // 执行失败
| 'Skipped'; // 因上游失败或策略原因被跳过
// ============================================================================
// Graph Structure (DAG)
// ============================================================================
export interface TaskNode {
id: string;
name: string;
type: TaskType;
initial_status: TaskStatus;
}
export interface TaskDependency {
from: string;
to: string;
}
export interface WorkflowDag {
nodes: TaskNode[];
edges: TaskDependency[];
}
// ============================================================================
// Events (Server-Sent Events Payloads)
// ============================================================================
/**
* Base interface for all workflow events
* Discriminated union based on 'type' field
*/
export type WorkflowEvent =
| {
type: 'WorkflowStarted';
payload: {
timestamp: number;
task_graph: WorkflowDag;
};
}
| {
type: 'TaskStateChanged';
payload: {
task_id: string;
task_type: TaskType;
status: TaskStatus;
message: string | null;
timestamp: number;
};
}
| {
type: 'TaskStreamUpdate';
payload: {
task_id: string;
content_delta: string;
index: number;
};
}
| {
type: 'WorkflowCompleted';
payload: {
result_summary: any; // JSON Value
end_timestamp: number;
};
}
| {
type: 'WorkflowFailed';
payload: {
reason: string;
is_fatal: boolean;
end_timestamp: number;
};
}
| {
type: 'WorkflowStateSnapshot';
payload: {
timestamp: number;
task_graph: WorkflowDag;
tasks_status: Record<string, TaskStatus>;
tasks_output: Record<string, string | null>;
};
};
// ============================================================================
// API Request/Response Types
// ============================================================================
export interface StartWorkflowRequest {
symbol: string;
market?: string;
template_id: string;
}
export interface StartWorkflowResponse {
request_id: string;
symbol: string;
market: string;
}

View File

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

View File

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

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>temp_init</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,58 @@
{
"name": "frontend",
"version": "0.1.0",
"name": "temp_init",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "NODE_NO_WARNINGS=1 next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.5.1",
"github-markdown-css": "^5.8.1",
"lucide-react": "^0.545.0",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"cmdk": "^1.1.1",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"recharts": "^3.3.0",
"react-router-dom": "^7.9.6",
"reactflow": "^11.11.4",
"remark-gfm": "^4.0.1",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-typography": "^3.1.0",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"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"
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More