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.
@ -52,7 +52,7 @@ services:
|
|||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
container_name: fundamental-frontend
|
container_name: fundamental-frontend
|
||||||
working_dir: /workspace/frontend
|
working_dir: /workspace/frontend
|
||||||
command: npm run dev
|
command: ["/workspace/frontend/scripts/docker-dev-entrypoint.sh"]
|
||||||
environment:
|
environment:
|
||||||
# 让 Next 的 API 路由代理到新的 api-gateway
|
# 让 Next 的 API 路由代理到新的 api-gateway
|
||||||
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
|
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
|
||||||
|
|||||||
63
docs/1_requirements/20251122_[Active]_user-guide_v2.md
Normal 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 页添加新的 Provider,Base URL 填入 `http://localhost:11434/v1` (Ollama 默认地址)。
|
||||||
|
|
||||||
|
|
||||||
400
docs/frontend/20251122_frontend_rebuild_design.md
Normal 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)。
|
||||||
75
docs/frontend/backend_todos.md
Normal 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
@ -1,43 +1,24 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Logs
|
||||||
|
logs
|
||||||
# dependencies
|
*.log
|
||||||
/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*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
node_modules
|
||||||
.env*
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# vercel
|
# Editor directories and files
|
||||||
.vercel
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
# typescript
|
.idea
|
||||||
*.tsbuildinfo
|
.DS_Store
|
||||||
next-env.d.ts
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
/src/generated/prisma
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|||||||
73
frontend/README.md
Normal 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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@ -16,7 +16,7 @@ const nextConfig = {
|
|||||||
// Optimize for Docker deployment only in production
|
// Optimize for Docker deployment only in production
|
||||||
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
|
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const apiUrl = process.env.API_GATEWAY_URL || 'http://api-gateway:4000';
|
const apiUrl = process.env.API_GATEWAY_URL || 'http://api-gateway:4000';
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
4085
frontend/archive/20251122_backup/package-lock.json
generated
Normal file
43
frontend/archive/v2_nextjs/.gitignore
vendored
Normal 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
|
||||||
22
frontend/archive/v2_nextjs/components.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
30
frontend/archive/v2_nextjs/next.config.mjs
Normal 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;
|
||||||
8931
frontend/archive/v2_nextjs/package-lock.json
generated
Normal file
45
frontend/archive/v2_nextjs/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
43
frontend/archive/v2_nextjs/scripts/docker-dev-entrypoint.sh
Executable 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}
|
||||||
|
|
||||||
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -329,7 +329,9 @@ export function AnalysisConfigTab() {
|
|||||||
|
|
||||||
{isCreatingModule && (
|
{isCreatingModule && (
|
||||||
<div className="space-y-4 p-4 border rounded-lg border-dashed">
|
<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">
|
||||||
|
在 “{localTemplateSets[selectedTemplateId].name}” 中新增分析模块
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-module-id">模块 ID (英文, 无空格)</Label>
|
<Label htmlFor="new-module-id">模块 ID (英文, 无空格)</Label>
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@ -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;
|
||||||
|
}
|
||||||
@ -2,14 +2,20 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { TaskStatus } from '@/types/workflow';
|
||||||
|
import { TaskInfo } from '@/hooks/useWorkflow';
|
||||||
|
|
||||||
interface FundamentalDataViewProps {
|
interface FundamentalDataViewProps {
|
||||||
taskStates: Record<string, TaskStatus>;
|
taskStates: Record<string, TaskStatus>;
|
||||||
|
taskInfos: Record<string, TaskInfo>;
|
||||||
taskOutputs: Record<string, string>;
|
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
|
// Filter tasks that look like data fetching tasks
|
||||||
const dataTasks = Object.keys(taskStates).filter(taskId =>
|
const dataTasks = Object.keys(taskStates).filter(taskId =>
|
||||||
taskId.startsWith('fetch:') // Standardized task ID format: "fetch:provider_id"
|
taskId.startsWith('fetch:') // Standardized task ID format: "fetch:provider_id"
|
||||||
@ -28,6 +34,8 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
|
|||||||
{dataTasks.map(taskId => {
|
{dataTasks.map(taskId => {
|
||||||
const status = taskStates[taskId];
|
const status = taskStates[taskId];
|
||||||
const output = taskOutputs[taskId];
|
const output = taskOutputs[taskId];
|
||||||
|
const info = taskInfos[taskId];
|
||||||
|
|
||||||
// Dynamic name resolution: extract provider ID from task ID (e.g., "fetch:tushare" -> "Tushare")
|
// Dynamic name resolution: extract provider ID from task ID (e.g., "fetch:tushare" -> "Tushare")
|
||||||
const providerId = taskId.replace('fetch:', '');
|
const providerId = taskId.replace('fetch:', '');
|
||||||
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
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}>
|
<CardTitle className="text-lg font-medium truncate" title={providerName}>
|
||||||
{providerName}
|
{providerName}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} message={info?.message} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 min-h-0 pt-2">
|
<CardContent className="flex-1 min-h-0 pt-2">
|
||||||
<ScrollArea className="h-full w-full border rounded-md bg-muted/5 p-4">
|
<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">
|
<pre className="text-xs font-mono whitespace-pre-wrap break-words text-foreground/80">
|
||||||
{tryFormatJson(output)}
|
{tryFormatJson(output)}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm italic">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@ -60,17 +73,48 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: TaskStatus }) {
|
function StatusBadge({ status, message }: { status: TaskStatus, message?: string }) {
|
||||||
switch (status) {
|
let badge = <Badge variant="outline">Pending</Badge>;
|
||||||
case 'completed':
|
|
||||||
return <Badge variant="default" className="bg-green-600">Success</Badge>;
|
switch (status) {
|
||||||
case 'failed':
|
case 'Completed':
|
||||||
return <Badge variant="destructive">Failed</Badge>;
|
badge = <Badge variant="default" className="bg-green-600 hover:bg-green-700">Success</Badge>;
|
||||||
case 'running':
|
break;
|
||||||
return <Badge variant="secondary" className="animate-pulse text-blue-500">Fetching</Badge>;
|
case 'Failed':
|
||||||
default:
|
badge = <Badge variant="destructive">Failed</Badge>;
|
||||||
return <Badge variant="outline">Pending</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:
|
||||||
|
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 {
|
function tryFormatJson(str: string): string {
|
||||||
@ -87,4 +131,3 @@ function tryFormatJson(str: string): string {
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,21 +56,30 @@ export function WorkflowReportLayout({
|
|||||||
.catch(err => console.error('Failed to load templates:', err));
|
.catch(err => console.error('Failed to load templates:', err));
|
||||||
}, [selectedTemplateId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (workflow.status === 'connecting' || workflow.status === 'connected') {
|
if (workflow.status === 'connecting' || workflow.status === 'connected') {
|
||||||
setActiveTab("analysis");
|
setActiveTab("analysis");
|
||||||
}
|
}
|
||||||
}, [workflow.status]);
|
}, [workflow.status]);
|
||||||
|
*/
|
||||||
|
|
||||||
const handleStart = async () => {
|
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({
|
const response = await workflow.startWorkflow({
|
||||||
symbol,
|
symbol,
|
||||||
market: initialMarket,
|
market: initialMarket,
|
||||||
template_id: selectedTemplateId
|
template_id: selectedTemplateId
|
||||||
});
|
});
|
||||||
|
console.log('[WorkflowReportLayout] startWorkflow returned:', response);
|
||||||
|
|
||||||
// Handle Symbol Normalization Redirection
|
// Handle Symbol Normalization Redirection
|
||||||
if (response && response.symbol && response.symbol !== symbol) {
|
if (response && response.symbol && response.symbol !== symbol) {
|
||||||
@ -171,6 +180,7 @@ export function WorkflowReportLayout({
|
|||||||
<TabsContent value="data" className="mt-4">
|
<TabsContent value="data" className="mt-4">
|
||||||
<FundamentalDataView
|
<FundamentalDataView
|
||||||
taskStates={workflow.taskStates}
|
taskStates={workflow.taskStates}
|
||||||
|
taskInfos={workflow.taskInfos}
|
||||||
taskOutputs={workflow.taskOutputs}
|
taskOutputs={workflow.taskOutputs}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@ -180,6 +190,7 @@ export function WorkflowReportLayout({
|
|||||||
{workflow.requestId ? (
|
{workflow.requestId ? (
|
||||||
<AnalysisModulesView
|
<AnalysisModulesView
|
||||||
taskStates={workflow.taskStates}
|
taskStates={workflow.taskStates}
|
||||||
|
taskInfos={workflow.taskInfos}
|
||||||
taskOutputs={workflow.taskOutputs}
|
taskOutputs={workflow.taskOutputs}
|
||||||
modulesConfig={dynamicModules}
|
modulesConfig={dynamicModules}
|
||||||
/>
|
/>
|
||||||
@ -272,4 +283,3 @@ function EmptyState({ onStart, message }: { onStart: () => void, message: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Removed FinalReportView as it is now superseded by AnalysisModulesView
|
// Removed FinalReportView as it is now superseded by AnalysisModulesView
|
||||||
|
|
||||||
@ -120,7 +120,11 @@ export function TradingViewWidget({
|
|||||||
// TradingView 的 embed 脚本会在内部创建 iframe
|
// TradingView 的 embed 脚本会在内部创建 iframe
|
||||||
// 如果容器正在被卸载,或者 iframe 尚未完全准备好,可能会触发该错误
|
// 如果容器正在被卸载,或者 iframe 尚未完全准备好,可能会触发该错误
|
||||||
// 我们只是 append script,实际的 iframe 是由 TradingView 脚本注入的
|
// 我们只是 append script,实际的 iframe 是由 TradingView 脚本注入的
|
||||||
container.appendChild(script);
|
try {
|
||||||
|
container.appendChild(script);
|
||||||
|
} catch (appendError) {
|
||||||
|
console.warn('[TradingView] Script append failed:', appendError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略偶发性 contentWindow 不可用的报错
|
// 忽略偶发性 contentWindow 不可用的报错
|
||||||
164
frontend/archive/v2_nextjs/src/components/ui/add-row-menu.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AddRowMenuProps {
|
||||||
|
/** 添加新行回调 */
|
||||||
|
onAddRow: (rowType: 'separator', customText?: string) => void;
|
||||||
|
/** 是否禁用 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 添加行菜单组件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function AddRowMenu({ onAddRow, disabled = false, className }: AddRowMenuProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [customText, setCustomText] = useState("");
|
||||||
|
// 移除selectedType,因为只支持分隔线
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 点击外部关闭菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setCustomText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
// 自动聚焦到输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleAddRow = () => {
|
||||||
|
const text = customText.trim() || "分组标题";
|
||||||
|
onAddRow('separator', text);
|
||||||
|
setIsOpen(false);
|
||||||
|
setCustomText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
添加分隔线
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className={cn("relative", className)}>
|
||||||
|
<div className="absolute top-0 left-0 z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-80 animate-in fade-in-0 zoom-in-95 duration-200">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">添加新行</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setCustomText("");
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 行类型选择 - 只显示分隔线 */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<label className="text-xs font-medium text-gray-700">添加分隔线</label>
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||||
|
{getTypeIcon()}
|
||||||
|
<span>分隔线</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
用于分组显示不同类型的指标,点击分隔线不会影响图表显示
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义文本输入 */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<label className="text-xs font-medium text-gray-700">
|
||||||
|
分隔线文字
|
||||||
|
<span className="text-gray-500 ml-1">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={customText}
|
||||||
|
onChange={(e) => setCustomText(e.target.value)}
|
||||||
|
placeholder="分组标题"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddRow();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
setCustomText("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setCustomText("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddRow}
|
||||||
|
>
|
||||||
|
添加分隔线
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/archive/v2_nextjs/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
60
frontend/archive/v2_nextjs/src/components/ui/button.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
frontend/archive/v2_nextjs/src/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
frontend/archive/v2_nextjs/src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
160
frontend/archive/v2_nextjs/src/components/ui/command.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
48
frontend/archive/v2_nextjs/src/components/ui/dialog.tsx
Normal 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>;
|
||||||
|
};
|
||||||
289
frontend/archive/v2_nextjs/src/components/ui/draggable-row.tsx
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TableRow, TableCell } from "@/components/ui/table";
|
||||||
|
import type { TableRowData } from "./enhanced-table";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DraggableRowProps {
|
||||||
|
/** 行数据 */
|
||||||
|
rowData: TableRowData;
|
||||||
|
/** 列数组 */
|
||||||
|
columns: string[];
|
||||||
|
/** 行索引 */
|
||||||
|
index: number;
|
||||||
|
/** 是否可见 */
|
||||||
|
isVisible: boolean;
|
||||||
|
/** 是否选中 */
|
||||||
|
isSelected: boolean;
|
||||||
|
/** 是否启用拖拽 */
|
||||||
|
isDraggable: boolean;
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
isDragging: boolean;
|
||||||
|
/** 是否为拖拽目标 */
|
||||||
|
isDragOver: boolean;
|
||||||
|
/** 动画持续时间 */
|
||||||
|
animationDuration: number;
|
||||||
|
/** 点击回调 */
|
||||||
|
onClick: () => void;
|
||||||
|
/** 拖拽开始回调 */
|
||||||
|
onDragStart: (index: number) => void;
|
||||||
|
/** 拖拽结束回调 */
|
||||||
|
onDragEnd: () => void;
|
||||||
|
/** 拖拽进入回调 */
|
||||||
|
onDragEnter: (index: number) => void;
|
||||||
|
/** 拖拽离开回调 */
|
||||||
|
onDragLeave: () => void;
|
||||||
|
/** 拖拽悬停回调 */
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
/** 放置回调 */
|
||||||
|
onDrop: (e: React.DragEvent) => void;
|
||||||
|
/** 删除自定义行回调 */
|
||||||
|
onDeleteCustomRow?: (rowId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 拖拽行组件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DraggableRow = React.memo<DraggableRowProps>(({
|
||||||
|
rowData,
|
||||||
|
columns,
|
||||||
|
index,
|
||||||
|
isVisible,
|
||||||
|
isSelected,
|
||||||
|
isDraggable,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
animationDuration,
|
||||||
|
onClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDeleteCustomRow
|
||||||
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
|
if (!isDraggable) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', rowData.id);
|
||||||
|
onDragStart(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// 如果点击的是拖拽手柄或删除按钮,不触发行点击
|
||||||
|
if (
|
||||||
|
dragHandleRef.current?.contains(e.target as Node) ||
|
||||||
|
(e.target as HTMLElement).closest('.delete-button')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是自定义行(特别是分隔线),不触发行点击
|
||||||
|
if (rowData.isCustomRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCustomRow = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onDeleteCustomRow && rowData.isCustomRow) {
|
||||||
|
onDeleteCustomRow(rowData.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染自定义行内容
|
||||||
|
const renderCustomRowContent = () => {
|
||||||
|
if (!rowData.isCustomRow) return null;
|
||||||
|
|
||||||
|
switch (rowData.customRowType) {
|
||||||
|
case 'empty':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableCell className="font-medium text-gray-500 italic">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isDraggable && (
|
||||||
|
<div
|
||||||
|
ref={dragHandleRef}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded"
|
||||||
|
title="拖拽排序"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span>{rowData.displayText}</span>
|
||||||
|
{onDeleteCustomRow && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteCustomRow}
|
||||||
|
className="delete-button ml-auto p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="删除此行"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column} className="text-right text-gray-400 italic">
|
||||||
|
{column}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'separator':
|
||||||
|
return (
|
||||||
|
<TableCell colSpan={columns.length + 1} className="py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isDraggable && (
|
||||||
|
<div
|
||||||
|
ref={dragHandleRef}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded"
|
||||||
|
title="拖拽排序"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 border-t border-gray-300"></div>
|
||||||
|
<span className="text-sm text-gray-500 px-2">{rowData.displayText}</span>
|
||||||
|
<div className="flex-1 border-t border-gray-300"></div>
|
||||||
|
{onDeleteCustomRow && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteCustomRow}
|
||||||
|
className="delete-button p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="删除此行"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'note':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableCell className="font-medium text-blue-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isDraggable && (
|
||||||
|
<div
|
||||||
|
ref={dragHandleRef}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded"
|
||||||
|
title="拖拽排序"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<svg className="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>{rowData.displayText}</span>
|
||||||
|
{onDeleteCustomRow && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteCustomRow}
|
||||||
|
className="delete-button ml-auto p-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="删除此行"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column} className="text-right text-gray-400">
|
||||||
|
-
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染普通数据行内容
|
||||||
|
const renderDataRowContent = () => (
|
||||||
|
<>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isDraggable && (
|
||||||
|
<div
|
||||||
|
ref={dragHandleRef}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="拖拽排序"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span>{rowData.displayText}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column} className="text-right">
|
||||||
|
{rowData.values[column] ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
draggable={isDraggable}
|
||||||
|
isVisible={isVisible}
|
||||||
|
animationDuration={animationDuration}
|
||||||
|
className={cn(
|
||||||
|
"group transition-all duration-200",
|
||||||
|
isSelected && "bg-indigo-50 border-indigo-200",
|
||||||
|
isDragging && "opacity-50 scale-95",
|
||||||
|
isDragOver && "bg-blue-50 border-blue-300 border-t-2",
|
||||||
|
isHovered && !isDragging && "bg-muted/70",
|
||||||
|
rowData.isCustomRow && "bg-gray-50/50",
|
||||||
|
!rowData.isCustomRow && "cursor-pointer hover:bg-muted/70",
|
||||||
|
rowData.isCustomRow && "cursor-default"
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragEnter={() => onDragEnter(index)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
{rowData.isCustomRow ? renderCustomRowContent() : renderDataRowContent()}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DraggableRow.displayName = "DraggableRow";
|
||||||
527
frontend/archive/v2_nextjs/src/components/ui/enhanced-table.tsx
Normal file
@ -0,0 +1,527 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强表格组件
|
||||||
|
*
|
||||||
|
* 提供以下功能:
|
||||||
|
* - 行级配置管理(显示/隐藏、排序)
|
||||||
|
* - 虚拟化渲染(大数据集优化)
|
||||||
|
* - 动画效果支持
|
||||||
|
* - 配置预览模式
|
||||||
|
* - 性能优化和错误处理
|
||||||
|
*
|
||||||
|
* @author Financial Analysis Platform Team
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useCallback, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { RowSettingsButton } from "@/components/ui/row-settings";
|
||||||
|
import { TableRowConfig } from "@/components/ui/row-settings";
|
||||||
|
import { DraggableRow } from "./draggable-row";
|
||||||
|
import { AddRowMenu } from "./add-row-menu";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表格行数据接口
|
||||||
|
*/
|
||||||
|
export interface TableRowData {
|
||||||
|
/** 行唯一标识符 */
|
||||||
|
id: string;
|
||||||
|
/** 显示文本 */
|
||||||
|
displayText: string;
|
||||||
|
/** 各年份/列的值 */
|
||||||
|
values: Record<string, string | number | null>;
|
||||||
|
/** 指标分组 */
|
||||||
|
group?: string;
|
||||||
|
/** API接口名 */
|
||||||
|
api?: string;
|
||||||
|
/** Tushare参数名 */
|
||||||
|
tushareParam?: string;
|
||||||
|
/** 是否为用户添加的自定义行 */
|
||||||
|
isCustomRow?: boolean;
|
||||||
|
/** 自定义行类型 */
|
||||||
|
customRowType?: 'empty' | 'separator' | 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强表格组件属性
|
||||||
|
*/
|
||||||
|
export interface EnhancedTableProps {
|
||||||
|
/** 表格数据 */
|
||||||
|
data: TableRowData[];
|
||||||
|
/** 列标题数组 */
|
||||||
|
columns: string[];
|
||||||
|
/** 行配置对象 */
|
||||||
|
rowConfigs: Record<string, TableRowConfig>;
|
||||||
|
/** 当前选中的行ID */
|
||||||
|
selectedRowId?: string;
|
||||||
|
/** 行点击回调 */
|
||||||
|
onRowClick?: (rowId: string) => void;
|
||||||
|
/** 配置变更回调 */
|
||||||
|
onRowConfigChange: (configs: Record<string, TableRowConfig>) => void;
|
||||||
|
/** 打开设置面板回调 */
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
/** 添加新行回调 */
|
||||||
|
onAddCustomRow?: (rowType: 'separator') => void;
|
||||||
|
/** 删除自定义行回调 */
|
||||||
|
onDeleteCustomRow?: (rowId: string) => void;
|
||||||
|
/** 行顺序变更回调 */
|
||||||
|
onRowOrderChange?: (newOrder: string[]) => void;
|
||||||
|
/** 自定义CSS类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 是否启用动画效果 */
|
||||||
|
enableAnimations?: boolean;
|
||||||
|
/** 动画持续时间(毫秒) */
|
||||||
|
animationDuration?: number;
|
||||||
|
/** 是否启用虚拟化渲染 */
|
||||||
|
enableVirtualization?: boolean;
|
||||||
|
/** 虚拟化最大可见行数 */
|
||||||
|
maxVisibleRows?: number;
|
||||||
|
/** 是否启用行拖拽排序 */
|
||||||
|
enableRowDragging?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 子组件类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟化行组件属性
|
||||||
|
*/
|
||||||
|
interface VirtualizedRowProps {
|
||||||
|
/** 行数据 */
|
||||||
|
rowData: TableRowData;
|
||||||
|
/** 列数组 */
|
||||||
|
columns: string[];
|
||||||
|
/** 是否可见 */
|
||||||
|
isVisible: boolean;
|
||||||
|
/** 是否选中 */
|
||||||
|
isSelected: boolean;
|
||||||
|
/** 点击回调 */
|
||||||
|
onClick: () => void;
|
||||||
|
/** 动画持续时间 */
|
||||||
|
animationDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟化行组件
|
||||||
|
*
|
||||||
|
* 使用React.memo优化性能,避免不必要的重渲染
|
||||||
|
*/
|
||||||
|
const VirtualizedRow = React.memo<VirtualizedRowProps>(({
|
||||||
|
rowData,
|
||||||
|
columns,
|
||||||
|
isVisible,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
animationDuration
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
isVisible={isVisible}
|
||||||
|
animationDuration={animationDuration}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all duration-200",
|
||||||
|
isSelected && "bg-indigo-50 border-indigo-200",
|
||||||
|
"hover:bg-muted/70"
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{rowData.displayText}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column} className="text-right">
|
||||||
|
{rowData.values[column] ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VirtualizedRow.displayName = "VirtualizedRow";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 主组件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强表格组件
|
||||||
|
*
|
||||||
|
* 提供行级配置、虚拟化渲染、动画效果等高级功能
|
||||||
|
*
|
||||||
|
* @param props - 组件属性
|
||||||
|
* @returns JSX元素
|
||||||
|
*/
|
||||||
|
export function EnhancedTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
rowConfigs,
|
||||||
|
selectedRowId,
|
||||||
|
onRowClick,
|
||||||
|
onRowConfigChange,
|
||||||
|
onOpenSettings,
|
||||||
|
onAddCustomRow,
|
||||||
|
onDeleteCustomRow,
|
||||||
|
onRowOrderChange,
|
||||||
|
className,
|
||||||
|
enableAnimations = true,
|
||||||
|
animationDuration = 300,
|
||||||
|
enableVirtualization = false,
|
||||||
|
maxVisibleRows = 50,
|
||||||
|
enableRowDragging = true
|
||||||
|
}: EnhancedTableProps) {
|
||||||
|
const [isConfigPreviewMode, setIsConfigPreviewMode] = useState(false);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 过滤和排序可见行
|
||||||
|
const visibleRows = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const filtered = data.filter(row => {
|
||||||
|
const config = rowConfigs[row.id];
|
||||||
|
return config?.isVisible !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保至少有一行可见
|
||||||
|
if (filtered.length === 0 && data.length > 0) {
|
||||||
|
console.warn('No visible rows found, showing first row as fallback');
|
||||||
|
return [data[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
const aOrder = rowConfigs[a.id]?.displayOrder ?? 0;
|
||||||
|
const bOrder = rowConfigs[b.id]?.displayOrder ?? 0;
|
||||||
|
return aOrder - bOrder;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error filtering visible rows:', error);
|
||||||
|
// 发生错误时返回所有数据作为后备
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}, [data, rowConfigs]);
|
||||||
|
|
||||||
|
// 虚拟化处理(仅在启用时)
|
||||||
|
const displayRows = useMemo(() => {
|
||||||
|
if (!enableVirtualization || visibleRows.length <= maxVisibleRows) {
|
||||||
|
return visibleRows;
|
||||||
|
}
|
||||||
|
// 简单的虚拟化:只显示前N行
|
||||||
|
return visibleRows.slice(0, maxVisibleRows);
|
||||||
|
}, [visibleRows, enableVirtualization, maxVisibleRows]);
|
||||||
|
|
||||||
|
// 行点击处理
|
||||||
|
const handleRowClick = useCallback((rowId: string) => {
|
||||||
|
if (onRowClick && !isConfigPreviewMode) {
|
||||||
|
onRowClick(rowId);
|
||||||
|
}
|
||||||
|
}, [onRowClick, isConfigPreviewMode]);
|
||||||
|
|
||||||
|
// 配置预览切换
|
||||||
|
const toggleConfigPreview = useCallback(() => {
|
||||||
|
setIsConfigPreviewMode(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 快速显示/隐藏行
|
||||||
|
const quickToggleRow = useCallback((rowId: string, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentConfig = rowConfigs[rowId] || { rowId, isVisible: true, displayOrder: 0 };
|
||||||
|
|
||||||
|
// 如果要隐藏行,检查是否会导致所有行都被隐藏
|
||||||
|
if (currentConfig.isVisible) {
|
||||||
|
const visibleCount = Object.values(rowConfigs).filter(config => config.isVisible).length;
|
||||||
|
if (visibleCount <= 1) {
|
||||||
|
console.warn('Cannot hide the last visible row');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfigs = {
|
||||||
|
...rowConfigs,
|
||||||
|
[rowId]: {
|
||||||
|
...currentConfig,
|
||||||
|
rowId,
|
||||||
|
isVisible: !currentConfig.isVisible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onRowConfigChange(newConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling row visibility:', error);
|
||||||
|
}
|
||||||
|
}, [rowConfigs, onRowConfigChange]);
|
||||||
|
|
||||||
|
// 拖拽排序处理
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
if (!enableRowDragging) return;
|
||||||
|
setDraggedIndex(index);
|
||||||
|
}, [enableRowDragging]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((index: number) => {
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (draggedIndex === null || draggedIndex === dropIndex || !enableRowDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 重新排序可见行
|
||||||
|
const newVisibleRows = [...displayRows];
|
||||||
|
const [draggedRow] = newVisibleRows.splice(draggedIndex, 1);
|
||||||
|
newVisibleRows.splice(dropIndex, 0, draggedRow);
|
||||||
|
|
||||||
|
// 更新配置中的显示顺序
|
||||||
|
const newConfigs = { ...rowConfigs };
|
||||||
|
newVisibleRows.forEach((row, index) => {
|
||||||
|
if (newConfigs[row.id]) {
|
||||||
|
newConfigs[row.id] = { ...newConfigs[row.id], displayOrder: index };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onRowConfigChange(newConfigs);
|
||||||
|
|
||||||
|
if (onRowOrderChange) {
|
||||||
|
const newOrder = newVisibleRows.map(row => row.id);
|
||||||
|
onRowOrderChange(newOrder);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reordering rows:', error);
|
||||||
|
}
|
||||||
|
}, [draggedIndex, displayRows, rowConfigs, onRowConfigChange, onRowOrderChange, enableRowDragging]);
|
||||||
|
|
||||||
|
// 添加自定义行处理
|
||||||
|
const handleAddCustomRow = useCallback((rowType: 'separator') => {
|
||||||
|
if (onAddCustomRow) {
|
||||||
|
onAddCustomRow(rowType);
|
||||||
|
}
|
||||||
|
}, [onAddCustomRow]);
|
||||||
|
|
||||||
|
// 删除自定义行处理
|
||||||
|
const handleDeleteCustomRow = useCallback((rowId: string) => {
|
||||||
|
if (onDeleteCustomRow) {
|
||||||
|
onDeleteCustomRow(rowId);
|
||||||
|
}
|
||||||
|
}, [onDeleteCustomRow]);
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = data.length;
|
||||||
|
const visible = visibleRows.length;
|
||||||
|
const hidden = total - visible;
|
||||||
|
return { total, visible, hidden };
|
||||||
|
}, [data.length, visibleRows.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full space-y-2", className)}>
|
||||||
|
{/* 表格控制栏 */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
显示 {stats.visible} / {stats.total} 行
|
||||||
|
{stats.hidden > 0 && (
|
||||||
|
<span className="text-orange-600 ml-1">
|
||||||
|
({stats.hidden} 行已隐藏)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{enableVirtualization && displayRows.length < visibleRows.length && (
|
||||||
|
<span className="text-blue-600">
|
||||||
|
虚拟化显示前 {displayRows.length} 行
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onAddCustomRow && (
|
||||||
|
<AddRowMenu
|
||||||
|
onAddRow={handleAddCustomRow}
|
||||||
|
disabled={isConfigPreviewMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isConfigPreviewMode && (
|
||||||
|
<span className="text-blue-600 text-xs">配置预览模式</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleConfigPreview}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs rounded transition-colors",
|
||||||
|
isConfigPreviewMode
|
||||||
|
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||||
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConfigPreviewMode ? "退出预览" : "配置预览"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
{displayRows.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<div className="text-sm">暂无可显示的数据行</div>
|
||||||
|
<div className="text-xs mt-1">请检查行配置设置</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-56">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>指标</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{enableRowDragging && (
|
||||||
|
<span className="text-xs text-gray-400" title="支持拖拽排序">
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<RowSettingsButton onClick={onOpenSettings} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead key={column} className="text-right">
|
||||||
|
{column}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{displayRows.map((row, index) => {
|
||||||
|
try {
|
||||||
|
const isSelected = selectedRowId === row.id;
|
||||||
|
const isVisible = rowConfigs[row.id]?.isVisible !== false;
|
||||||
|
const canHide = Object.values(rowConfigs).filter(config => config.isVisible).length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={row.id}>
|
||||||
|
{isConfigPreviewMode ? (
|
||||||
|
// 配置预览模式:显示快速切换按钮
|
||||||
|
<TableRow
|
||||||
|
isVisible={isVisible}
|
||||||
|
animationDuration={enableAnimations ? animationDuration : 0}
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-200",
|
||||||
|
isSelected && "bg-indigo-50 border-indigo-200",
|
||||||
|
!isVisible && "opacity-50 bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{row.displayText}</span>
|
||||||
|
{(row.group === "income" ||
|
||||||
|
row.group === "balancesheet" ||
|
||||||
|
row.group === "cashflow" ||
|
||||||
|
row.tushareParam === "total_mv") && (
|
||||||
|
<span className="text-xs text-muted-foreground">(亿元)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => quickToggleRow(row.id, e)}
|
||||||
|
disabled={isVisible && !canHide}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs rounded transition-colors",
|
||||||
|
isVisible && canHide && "bg-green-100 text-green-700 hover:bg-green-200",
|
||||||
|
!isVisible && "bg-red-100 text-red-700 hover:bg-red-200",
|
||||||
|
isVisible && !canHide && "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
title={isVisible && !canHide ? "至少需要保留一行可见" : undefined}
|
||||||
|
>
|
||||||
|
{isVisible ? "隐藏" : "显示"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column} className="text-right">
|
||||||
|
{row.values[column] ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
// 正常模式:可拖拽排序的行
|
||||||
|
<DraggableRow
|
||||||
|
rowData={row}
|
||||||
|
columns={columns}
|
||||||
|
index={index}
|
||||||
|
isVisible={isVisible}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDraggable={enableRowDragging}
|
||||||
|
isDragging={draggedIndex === index}
|
||||||
|
isDragOver={dragOverIndex === index}
|
||||||
|
animationDuration={enableAnimations ? animationDuration : 0}
|
||||||
|
onClick={() => handleRowClick(row.id)}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDeleteCustomRow={handleDeleteCustomRow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering table row:', error);
|
||||||
|
return (
|
||||||
|
<TableRow key={`error-${row.id}`}>
|
||||||
|
<TableCell colSpan={columns.length + 1} className="text-center text-red-500 text-sm">
|
||||||
|
渲染行数据时出错: {row.displayText}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 性能提示 */}
|
||||||
|
{data.length > 100 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-2">
|
||||||
|
<span>
|
||||||
|
大量数据已优化渲染性能
|
||||||
|
{enableVirtualization && ` (虚拟化已启用)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types are already exported with their interface declarations
|
||||||
21
frontend/archive/v2_nextjs/src/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
21
frontend/archive/v2_nextjs/src/components/ui/label.tsx
Normal 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 }
|
||||||
168
frontend/archive/v2_nextjs/src/components/ui/navigation-menu.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
}
|
||||||
119
frontend/archive/v2_nextjs/src/components/ui/notification.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface NotificationProps {
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
isVisible: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
autoHide?: boolean;
|
||||||
|
autoHideDelay?: number;
|
||||||
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notification({
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
isVisible,
|
||||||
|
onDismiss,
|
||||||
|
autoHide = true,
|
||||||
|
autoHideDelay = 3000,
|
||||||
|
position = 'top-right'
|
||||||
|
}: NotificationProps) {
|
||||||
|
const [shouldRender, setShouldRender] = useState(isVisible);
|
||||||
|
|
||||||
|
// 处理显示/隐藏动画
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
setShouldRender(true);
|
||||||
|
} else {
|
||||||
|
const timer = setTimeout(() => setShouldRender(false), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
// 自动隐藏
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && autoHide && onDismiss) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onDismiss();
|
||||||
|
}, autoHideDelay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isVisible, autoHide, autoHideDelay, onDismiss]);
|
||||||
|
|
||||||
|
if (!shouldRender) return null;
|
||||||
|
|
||||||
|
const positionClasses = {
|
||||||
|
'top-right': 'top-4 right-4',
|
||||||
|
'top-left': 'top-4 left-4',
|
||||||
|
'bottom-right': 'bottom-4 right-4',
|
||||||
|
'bottom-left': 'bottom-4 left-4',
|
||||||
|
'top-center': 'top-4 left-1/2 transform -translate-x-1/2',
|
||||||
|
'bottom-center': 'bottom-4 left-1/2 transform -translate-x-1/2'
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeStyles = {
|
||||||
|
success: 'bg-green-50 border-green-200 text-green-800',
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: (
|
||||||
|
<svg className="h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg className="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg className="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg className="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 transition-all duration-300 ease-in-out",
|
||||||
|
positionClasses[position],
|
||||||
|
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border max-w-sm",
|
||||||
|
typeStyles[type]
|
||||||
|
)}>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{iconMap[type]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-sm font-medium">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="flex-shrink-0 rounded-full p-1 hover:bg-black hover:bg-opacity-10 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/archive/v2_nextjs/src/components/ui/popover.tsx
Normal 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 }
|
||||||
|
|
||||||
403
frontend/archive/v2_nextjs/src/components/ui/row-settings.tsx
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SortableRowItem } from "./sortable-row-item";
|
||||||
|
|
||||||
|
// 表格行配置接口
|
||||||
|
export interface TableRowConfig {
|
||||||
|
rowId: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
isCustomRow?: boolean;
|
||||||
|
customRowType?: 'empty' | 'separator' | 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行设置组件属性
|
||||||
|
export interface RowSettingsProps {
|
||||||
|
rowId: string;
|
||||||
|
displayText: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
onVisibilityChange: (rowId: string, visible: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置面板属性
|
||||||
|
export interface RowSettingsPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
rowConfigs: Record<string, TableRowConfig>;
|
||||||
|
rowDisplayTexts: Record<string, string>;
|
||||||
|
onConfigChange: (configs: Record<string, TableRowConfig>) => void;
|
||||||
|
onRowOrderChange?: (newOrder: string[]) => void;
|
||||||
|
onDeleteCustomRow?: (rowId: string) => void;
|
||||||
|
enableRowReordering?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个行设置组件
|
||||||
|
export function RowSettings({
|
||||||
|
rowId,
|
||||||
|
displayText,
|
||||||
|
isVisible,
|
||||||
|
onVisibilityChange
|
||||||
|
}: RowSettingsProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center justify-between py-3 px-4 hover:bg-gray-50 rounded-lg transition-colors border",
|
||||||
|
isVisible ? "border-green-200 bg-green-50/30" : "border-gray-200 bg-gray-50/30"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className={cn(
|
||||||
|
"w-2 h-2 rounded-full flex-shrink-0",
|
||||||
|
isVisible ? "bg-green-500" : "bg-gray-400"
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
isVisible ? "text-gray-900" : "text-gray-500"
|
||||||
|
)}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center cursor-pointer ml-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isVisible}
|
||||||
|
onChange={(e) => onVisibilityChange(rowId, e.target.checked)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ease-in-out focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2",
|
||||||
|
isVisible ? "bg-blue-600" : "bg-gray-300"
|
||||||
|
)}>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-block h-5 w-5 transform rounded-full bg-white shadow-lg transition-transform duration-200 ease-in-out",
|
||||||
|
isVisible ? "translate-x-5" : "translate-x-0.5"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置面板组件
|
||||||
|
export function RowSettingsPanel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
rowConfigs,
|
||||||
|
rowDisplayTexts,
|
||||||
|
onConfigChange,
|
||||||
|
onRowOrderChange,
|
||||||
|
onDeleteCustomRow,
|
||||||
|
enableRowReordering = true
|
||||||
|
}: RowSettingsPanelProps) {
|
||||||
|
const [localConfigs, setLocalConfigs] = useState(() => rowConfigs);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Sync local configs when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setLocalConfigs(rowConfigs);
|
||||||
|
}
|
||||||
|
}, [isOpen, rowConfigs]);
|
||||||
|
|
||||||
|
const handleVisibilityChange = (rowId: string, visible: boolean) => {
|
||||||
|
try {
|
||||||
|
// 如果要隐藏行,检查是否会导致所有行都被隐藏
|
||||||
|
if (!visible) {
|
||||||
|
const currentVisibleCount = Object.values(localConfigs).filter(config => config.isVisible).length;
|
||||||
|
if (currentVisibleCount <= 1) {
|
||||||
|
// 防止隐藏最后一个可见行
|
||||||
|
console.warn('Cannot hide the last visible row');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfigs = {
|
||||||
|
...localConfigs,
|
||||||
|
[rowId]: {
|
||||||
|
...localConfigs[rowId],
|
||||||
|
rowId,
|
||||||
|
isVisible: visible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setLocalConfigs(newConfigs);
|
||||||
|
onConfigChange(newConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change row visibility:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAll = () => {
|
||||||
|
try {
|
||||||
|
const newConfigs = { ...localConfigs };
|
||||||
|
Object.keys(newConfigs).forEach(rowId => {
|
||||||
|
newConfigs[rowId] = { ...newConfigs[rowId], isVisible: true };
|
||||||
|
});
|
||||||
|
setLocalConfigs(newConfigs);
|
||||||
|
onConfigChange(newConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to show all rows:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideAll = () => {
|
||||||
|
try {
|
||||||
|
const newConfigs = { ...localConfigs };
|
||||||
|
const configKeys = Object.keys(newConfigs);
|
||||||
|
|
||||||
|
// 防止隐藏所有行(至少保留一行可见)
|
||||||
|
if (configKeys.length <= 1) {
|
||||||
|
return; // 如果只有一行或没有行,不执行隐藏全部
|
||||||
|
}
|
||||||
|
|
||||||
|
configKeys.forEach(rowId => {
|
||||||
|
newConfigs[rowId] = { ...newConfigs[rowId], isVisible: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalConfigs(newConfigs);
|
||||||
|
onConfigChange(newConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to hide all rows:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
try {
|
||||||
|
const newConfigs = { ...localConfigs };
|
||||||
|
Object.keys(newConfigs).forEach(rowId => {
|
||||||
|
newConfigs[rowId] = { ...newConfigs[rowId], isVisible: true };
|
||||||
|
});
|
||||||
|
setLocalConfigs(newConfigs);
|
||||||
|
onConfigChange(newConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset configuration:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖拽排序处理
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (index: number) => {
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (draggedIndex === null || draggedIndex === dropIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sortedEntries = Object.entries(localConfigs)
|
||||||
|
.sort(([, a], [, b]) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||||
|
|
||||||
|
const [draggedItem] = sortedEntries.splice(draggedIndex, 1);
|
||||||
|
sortedEntries.splice(dropIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
const newConfigs = { ...localConfigs };
|
||||||
|
sortedEntries.forEach(([rowId], index) => {
|
||||||
|
newConfigs[rowId] = { ...newConfigs[rowId], displayOrder: index };
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalConfigs(newConfigs);
|
||||||
|
onConfigChange(newConfigs);
|
||||||
|
|
||||||
|
if (onRowOrderChange) {
|
||||||
|
const newOrder = sortedEntries.map(([rowId]) => rowId);
|
||||||
|
onRowOrderChange(newOrder);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder rows:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除自定义行处理
|
||||||
|
const handleDeleteCustomRow = (rowId: string) => {
|
||||||
|
try {
|
||||||
|
if (onDeleteCustomRow) {
|
||||||
|
onDeleteCustomRow(rowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfigs = { ...localConfigs };
|
||||||
|
delete newConfigs[rowId];
|
||||||
|
setLocalConfigs(newConfigs);
|
||||||
|
onConfigChange(newConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete custom row:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const visibleCount = Object.values(localConfigs).filter(config => config.isVisible).length;
|
||||||
|
const totalCount = Object.keys(localConfigs).length;
|
||||||
|
const canHideMore = visibleCount > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] flex flex-col animate-in fade-in-0 zoom-in-95 duration-200">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">表格行设置</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
显示 {visibleCount} / {totalCount} 行
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 p-4 sm:p-6 border-b bg-gray-50">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleShowAll}
|
||||||
|
className="flex-1 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
全部显示
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleHideAll}
|
||||||
|
disabled={!canHideMore}
|
||||||
|
className="flex-1 text-xs sm:text-sm"
|
||||||
|
title={!canHideMore ? "至少需要保留一行可见" : "隐藏所有行"}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
全部隐藏
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex-1 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 行配置列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-2">
|
||||||
|
{enableRowReordering ? (
|
||||||
|
// 可拖拽排序的行列表
|
||||||
|
Object.entries(localConfigs)
|
||||||
|
.sort(([, a], [, b]) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0))
|
||||||
|
.map(([rowId, config], index) => (
|
||||||
|
<SortableRowItem
|
||||||
|
key={rowId}
|
||||||
|
rowId={rowId}
|
||||||
|
displayText={rowDisplayTexts[rowId] || rowId}
|
||||||
|
config={config}
|
||||||
|
index={index}
|
||||||
|
isDragging={draggedIndex === index}
|
||||||
|
isDragOver={dragOverIndex === index}
|
||||||
|
onVisibilityChange={handleVisibilityChange}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDeleteCustomRow={handleDeleteCustomRow}
|
||||||
|
enableDragging={enableRowReordering}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// 传统的行列表(不可拖拽)
|
||||||
|
Object.entries(localConfigs)
|
||||||
|
.sort(([, a], [, b]) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0))
|
||||||
|
.map(([rowId, config]) => (
|
||||||
|
<RowSettings
|
||||||
|
key={rowId}
|
||||||
|
rowId={rowId}
|
||||||
|
displayText={rowDisplayTexts[rowId] || rowId}
|
||||||
|
isVisible={config.isVisible}
|
||||||
|
onVisibilityChange={handleVisibilityChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部 */}
|
||||||
|
<div className="p-4 sm:p-6 border-t bg-gray-50">
|
||||||
|
{/* 警告信息 */}
|
||||||
|
{visibleCount <= 1 && (
|
||||||
|
<div className="mb-3 p-2 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-800">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg className="h-3 w-3 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>至少需要保留一行可见以确保表格正常显示</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||||
|
<div className="text-xs text-gray-500 text-center sm:text-left">
|
||||||
|
配置将自动保存并在下次访问时恢复
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>显示 {visibleCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<span>隐藏 {totalCount - visibleCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置按钮组件
|
||||||
|
export function RowSettingsButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
title="配置表格行显示"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/archive/v2_nextjs/src/components/ui/scroll-area.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
187
frontend/archive/v2_nextjs/src/components/ui/select.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
29
frontend/archive/v2_nextjs/src/components/ui/separator.tsx
Normal 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 }
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/archive/v2_nextjs/src/components/ui/spinner.tsx
Normal 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 }
|
||||||
325
frontend/archive/v2_nextjs/src/components/ui/status-bar.tsx
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态栏组件
|
||||||
|
*
|
||||||
|
* 用于显示执行步骤的进度和状态信息,支持:
|
||||||
|
* - 多步骤进度显示
|
||||||
|
* - 成功/错误状态处理
|
||||||
|
* - 重试功能
|
||||||
|
* - 自动隐藏机制
|
||||||
|
* - 平滑动画效果
|
||||||
|
*
|
||||||
|
* @author Financial Analysis Platform Team
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行步骤接口定义
|
||||||
|
*/
|
||||||
|
export interface ExecutionStep {
|
||||||
|
/** 步骤唯一标识符 */
|
||||||
|
id: string;
|
||||||
|
/** 步骤显示名称 */
|
||||||
|
name: string;
|
||||||
|
/** 步骤详细描述 */
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态栏组件属性接口
|
||||||
|
*/
|
||||||
|
export interface StatusBarProps {
|
||||||
|
/** 是否可见 */
|
||||||
|
isVisible: boolean;
|
||||||
|
/** 当前执行步骤 */
|
||||||
|
currentStep: ExecutionStep | null;
|
||||||
|
/** 当前步骤索引 */
|
||||||
|
stepIndex: number;
|
||||||
|
/** 总步骤数 */
|
||||||
|
totalSteps: number;
|
||||||
|
/** 执行状态 */
|
||||||
|
status: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
/** 错误信息 */
|
||||||
|
errorMessage?: string;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onDismiss?: () => void;
|
||||||
|
/** 重试回调 */
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** 是否可重试 */
|
||||||
|
retryable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态栏状态接口
|
||||||
|
*/
|
||||||
|
export interface StatusBarState {
|
||||||
|
/** 是否可见 */
|
||||||
|
isVisible: boolean;
|
||||||
|
/** 当前执行步骤 */
|
||||||
|
currentStep: ExecutionStep | null;
|
||||||
|
/** 当前步骤索引 */
|
||||||
|
stepIndex: number;
|
||||||
|
/** 总步骤数 */
|
||||||
|
totalSteps: number;
|
||||||
|
/** 执行状态 */
|
||||||
|
status: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
/** 错误信息 */
|
||||||
|
errorMessage?: string;
|
||||||
|
/** 是否可重试 */
|
||||||
|
retryable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预定义的执行步骤已移至 ExecutionStepManager (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
|
||||||
|
};
|
||||||
|
}
|
||||||
24
frontend/archive/v2_nextjs/src/components/ui/switch.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
159
frontend/archive/v2_nextjs/src/components/ui/table.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
||||||
|
isVisible?: boolean;
|
||||||
|
animationDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
TableRowProps
|
||||||
|
>(({ className, isVisible = true, animationDuration = 300, style, ...props }, ref) => {
|
||||||
|
const [shouldRender, setShouldRender] = React.useState(isVisible);
|
||||||
|
const [isAnimating, setIsAnimating] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isVisible && !shouldRender) {
|
||||||
|
// Show animation
|
||||||
|
setShouldRender(true);
|
||||||
|
setIsAnimating(true);
|
||||||
|
const timer = setTimeout(() => setIsAnimating(false), animationDuration);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (!isVisible && shouldRender) {
|
||||||
|
// Hide animation
|
||||||
|
setIsAnimating(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShouldRender(false);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, animationDuration);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isVisible, shouldRender, animationDuration]);
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationStyle: React.CSSProperties = {
|
||||||
|
...style,
|
||||||
|
transition: `all ${animationDuration}ms ease-in-out`,
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
transform: isVisible ? 'translateY(0)' : 'translateY(-10px)',
|
||||||
|
maxHeight: isVisible ? '1000px' : '0px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
isAnimating && "pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={animationStyle}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-2 align-middle whitespace-nowrap", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
66
frontend/archive/v2_nextjs/src/components/ui/tabs.tsx
Normal 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 }
|
||||||
25
frontend/archive/v2_nextjs/src/components/ui/textarea.tsx
Normal 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 }
|
||||||
|
|
||||||
29
frontend/archive/v2_nextjs/src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
|
|
||||||
@ -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" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -222,11 +222,14 @@ export function useRealtimeQuote(
|
|||||||
|
|
||||||
export function useConfig() {
|
export function useConfig() {
|
||||||
const { setConfig, setError, setLoading } = useConfigStore();
|
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(() => {
|
useEffect(() => {
|
||||||
setLoading(Boolean(isLoading));
|
setLoading(Boolean(isLoading));
|
||||||
if (error) {
|
if (error) {
|
||||||
setError(error.message || '加载配置失败');
|
setError('加载配置失败');
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
}
|
}
|
||||||
@ -235,24 +238,8 @@ export function useConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateConfig(payload: Partial<SystemConfig>) {
|
export async function updateConfig(payload: Partial<SystemConfig>) {
|
||||||
const res = await fetch('/api/config', {
|
// Deprecated
|
||||||
method: 'PUT',
|
throw new Error("updateConfig is deprecated. Use specific update functions.");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testConfig(type: string, data: any) {
|
export async function testConfig(type: string, data: any) {
|
||||||
@ -273,7 +260,7 @@ export async function testConfig(type: string, data: any) {
|
|||||||
try {
|
try {
|
||||||
const err = JSON.parse(text);
|
const err = JSON.parse(text);
|
||||||
// 优先从标准字段中提取错误信息;同时分离 details
|
// 优先从标准字段中提取错误信息;同时分离 details
|
||||||
let message: string = err?.error || err?.message || '';
|
const message: string = err?.error || err?.message || '';
|
||||||
let detailsStr = '';
|
let detailsStr = '';
|
||||||
if (err?.details !== undefined) {
|
if (err?.details !== undefined) {
|
||||||
if (typeof err.details === 'string') {
|
if (typeof err.details === 'string') {
|
||||||
@ -4,17 +4,27 @@ import {
|
|||||||
WorkflowDag,
|
WorkflowDag,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
StartWorkflowRequest,
|
StartWorkflowRequest,
|
||||||
StartWorkflowResponse
|
StartWorkflowResponse,
|
||||||
|
TaskType
|
||||||
} from '@/types/workflow';
|
} from '@/types/workflow';
|
||||||
|
|
||||||
export type WorkflowConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
export type WorkflowConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||||
|
|
||||||
|
export interface TaskInfo {
|
||||||
|
taskId: string;
|
||||||
|
type?: TaskType;
|
||||||
|
status: TaskStatus;
|
||||||
|
message?: string;
|
||||||
|
lastUpdate: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseWorkflowReturn {
|
interface UseWorkflowReturn {
|
||||||
// State
|
// State
|
||||||
status: WorkflowConnectionStatus;
|
status: WorkflowConnectionStatus;
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
dag: WorkflowDag | null;
|
dag: WorkflowDag | null;
|
||||||
taskStates: Record<string, TaskStatus>;
|
taskStates: Record<string, TaskStatus>;
|
||||||
|
taskInfos: Record<string, TaskInfo>; // Added for rich metadata
|
||||||
taskOutputs: Record<string, string>; // Accumulates streaming content
|
taskOutputs: Record<string, string>; // Accumulates streaming content
|
||||||
error: string | null;
|
error: string | null;
|
||||||
finalResult: any | null;
|
finalResult: any | null;
|
||||||
@ -31,6 +41,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
const [requestId, setRequestId] = useState<string | null>(null);
|
const [requestId, setRequestId] = useState<string | null>(null);
|
||||||
const [dag, setDag] = useState<WorkflowDag | null>(null);
|
const [dag, setDag] = useState<WorkflowDag | null>(null);
|
||||||
const [taskStates, setTaskStates] = useState<Record<string, TaskStatus>>({});
|
const [taskStates, setTaskStates] = useState<Record<string, TaskStatus>>({});
|
||||||
|
const [taskInfos, setTaskInfos] = useState<Record<string, TaskInfo>>({});
|
||||||
const [taskOutputs, setTaskOutputs] = useState<Record<string, string>>({});
|
const [taskOutputs, setTaskOutputs] = useState<Record<string, string>>({});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [finalResult, setFinalResult] = useState<any | null>(null);
|
const [finalResult, setFinalResult] = useState<any | null>(null);
|
||||||
@ -50,25 +61,48 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEvent = useCallback((eventData: WorkflowEvent) => {
|
const handleEvent = useCallback((eventData: WorkflowEvent) => {
|
||||||
|
console.log('[useWorkflow] Handling event type:', eventData.type);
|
||||||
switch (eventData.type) {
|
switch (eventData.type) {
|
||||||
case 'WorkflowStarted':
|
case 'WorkflowStarted':
|
||||||
|
console.log('[useWorkflow] WorkflowStarted. Nodes:', eventData.payload.task_graph.nodes.length);
|
||||||
setDag(eventData.payload.task_graph);
|
setDag(eventData.payload.task_graph);
|
||||||
// Initialize states based on graph
|
// Initialize states based on graph
|
||||||
const initialStates: Record<string, TaskStatus> = {};
|
const initialStates: Record<string, TaskStatus> = {};
|
||||||
|
const initialInfos: Record<string, TaskInfo> = {};
|
||||||
eventData.payload.task_graph.nodes.forEach(node => {
|
eventData.payload.task_graph.nodes.forEach(node => {
|
||||||
initialStates[node.id] = node.initial_status;
|
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);
|
setTaskStates(initialStates);
|
||||||
|
setTaskInfos(initialInfos);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'TaskStateChanged':
|
case 'TaskStateChanged':
|
||||||
|
const { task_id, status, message, timestamp, task_type } = eventData.payload;
|
||||||
|
console.log(`[useWorkflow] TaskStateChanged: ${task_id} -> ${status}`);
|
||||||
setTaskStates(prev => ({
|
setTaskStates(prev => ({
|
||||||
...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;
|
break;
|
||||||
|
|
||||||
case 'TaskStreamUpdate':
|
case 'TaskStreamUpdate':
|
||||||
|
// console.log(`[useWorkflow] StreamUpdate for ${eventData.payload.task_id}, len: ${eventData.payload.content_delta.length}`);
|
||||||
setTaskOutputs(prev => ({
|
setTaskOutputs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[eventData.payload.task_id]: (prev[eventData.payload.task_id] || '') + eventData.payload.content_delta
|
[eventData.payload.task_id]: (prev[eventData.payload.task_id] || '') + eventData.payload.content_delta
|
||||||
@ -76,9 +110,23 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'WorkflowStateSnapshot':
|
case 'WorkflowStateSnapshot':
|
||||||
|
console.log('[useWorkflow] Snapshot received. Tasks:', Object.keys(eventData.payload.tasks_status).length);
|
||||||
// Restore full state
|
// Restore full state
|
||||||
setDag(eventData.payload.task_graph);
|
setDag(eventData.payload.task_graph);
|
||||||
setTaskStates(eventData.payload.tasks_status);
|
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
|
// Restore outputs if present
|
||||||
const outputs: Record<string, string> = {};
|
const outputs: Record<string, string> = {};
|
||||||
Object.entries(eventData.payload.tasks_output).forEach(([k, v]) => {
|
Object.entries(eventData.payload.tasks_output).forEach(([k, v]) => {
|
||||||
@ -88,11 +136,13 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'WorkflowCompleted':
|
case 'WorkflowCompleted':
|
||||||
|
console.log('[useWorkflow] Workflow Completed');
|
||||||
setFinalResult(eventData.payload.result_summary);
|
setFinalResult(eventData.payload.result_summary);
|
||||||
disconnect(); // Close connection on completion
|
disconnect(); // Close connection on completion
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'WorkflowFailed':
|
case 'WorkflowFailed':
|
||||||
|
console.error('[useWorkflow] Workflow Failed:', eventData.payload.reason);
|
||||||
setError(eventData.payload.reason);
|
setError(eventData.payload.reason);
|
||||||
// We might want to keep connected or disconnect depending on if retry is possible
|
// We might want to keep connected or disconnect depending on if retry is possible
|
||||||
// For now, treat fatal error as disconnect reason
|
// For now, treat fatal error as disconnect reason
|
||||||
@ -105,7 +155,9 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
}, [disconnect]);
|
}, [disconnect]);
|
||||||
|
|
||||||
const connectToWorkflow = useCallback((id: string) => {
|
const connectToWorkflow = useCallback((id: string) => {
|
||||||
|
console.log('[useWorkflow] connectToWorkflow called for ID:', id);
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
|
console.log('[useWorkflow] Closing existing EventSource');
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,27 +166,36 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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;
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = (e) => {
|
||||||
|
console.log(`[useWorkflow] SSE onopen triggered. URL: ${url}`, e);
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[useWorkflow] Raw SSE Message:', event.data);
|
||||||
const data = JSON.parse(event.data) as WorkflowEvent;
|
const data = JSON.parse(event.data) as WorkflowEvent;
|
||||||
|
console.log('[useWorkflow] Parsed Event:', data.type, data);
|
||||||
handleEvent(data);
|
handleEvent(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse workflow event:', e);
|
console.error('[useWorkflow] Failed to parse workflow event:', e, event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = (e) => {
|
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
|
// 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
|
// For now, let's assume if readyState is CLOSED, it's a fatal error
|
||||||
if (es.readyState === EventSource.CLOSED) {
|
if (es.readyState === EventSource.CLOSED) {
|
||||||
|
console.log('[useWorkflow] SSE Closed permanently');
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError('Connection lost');
|
setError('Connection lost');
|
||||||
es.close();
|
es.close();
|
||||||
@ -149,33 +210,41 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
}, [handleEvent]);
|
}, [handleEvent]);
|
||||||
|
|
||||||
const startWorkflow = useCallback(async (params: StartWorkflowRequest) => {
|
const startWorkflow = useCallback(async (params: StartWorkflowRequest) => {
|
||||||
|
console.log('[useWorkflow] startWorkflow called with params:', params);
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
setError(null);
|
setError(null);
|
||||||
setDag(null);
|
setDag(null);
|
||||||
setTaskStates({});
|
setTaskStates({});
|
||||||
|
setTaskInfos({});
|
||||||
setTaskOutputs({});
|
setTaskOutputs({});
|
||||||
setFinalResult(null);
|
setFinalResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[useWorkflow] Sending POST /api/workflow/start...');
|
||||||
const res = await fetch('/api/workflow/start', {
|
const res = await fetch('/api/workflow/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
});
|
});
|
||||||
|
console.log('[useWorkflow] POST response status:', res.status);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorBody = await res.json().catch(() => ({}));
|
const errorBody = await res.json().catch(() => ({}));
|
||||||
|
console.error('[useWorkflow] Start failed:', errorBody);
|
||||||
throw new Error(errorBody.error || `HTTP ${res.status}`);
|
throw new Error(errorBody.error || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: StartWorkflowResponse = await res.json();
|
const data: StartWorkflowResponse = await res.json();
|
||||||
|
console.log('[useWorkflow] Workflow started successfully. Response:', data);
|
||||||
|
|
||||||
// Start listening
|
// Start listening
|
||||||
|
console.log('[useWorkflow] Initiating SSE connection for requestId:', data.request_id);
|
||||||
connectToWorkflow(data.request_id);
|
connectToWorkflow(data.request_id);
|
||||||
|
|
||||||
return data; // Return response so UI can handle symbol normalization redirection
|
return data; // Return response so UI can handle symbol normalization redirection
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('[useWorkflow] Exception in startWorkflow:', e);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError(e instanceof Error ? e.message : 'Failed to start workflow');
|
setError(e instanceof Error ? e.message : 'Failed to start workflow');
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -196,6 +265,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
|||||||
requestId,
|
requestId,
|
||||||
dag,
|
dag,
|
||||||
taskStates,
|
taskStates,
|
||||||
|
taskInfos,
|
||||||
taskOutputs,
|
taskOutputs,
|
||||||
error,
|
error,
|
||||||
finalResult,
|
finalResult,
|
||||||
6
frontend/archive/v2_nextjs/src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
@ -1,23 +1,25 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { AnalysisTemplateSets, DataSourcesConfig, LlmProvidersConfig } from '@/types';
|
||||||
|
|
||||||
// 根据设计文档定义配置项的类型
|
// 根据设计文档定义配置项的类型(兼容 Legacy / 新结构)
|
||||||
export interface DatabaseConfig {
|
export interface DatabaseConfig {
|
||||||
url: string;
|
url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewApiConfig {
|
export interface NewApiConfig {
|
||||||
api_key: string;
|
api_key?: string | null;
|
||||||
base_url?: string;
|
base_url?: string | null;
|
||||||
}
|
provider_id?: string | null;
|
||||||
|
provider_name?: string | null;
|
||||||
export interface DataSourceConfig {
|
model_count?: number;
|
||||||
[key: string]: { api_key: string };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemConfig {
|
export interface SystemConfig {
|
||||||
database: DatabaseConfig;
|
database?: DatabaseConfig | null;
|
||||||
new_api: NewApiConfig;
|
new_api?: NewApiConfig | null;
|
||||||
data_sources: DataSourceConfig;
|
data_sources?: DataSourcesConfig;
|
||||||
|
llm_providers?: LlmProvidersConfig;
|
||||||
|
analysis_template_sets?: AnalysisTemplateSets;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigState {
|
interface ConfigState {
|
||||||
119
frontend/archive/v2_nextjs/src/types/workflow.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
27
frontend/archive/v2_nextjs/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", ".next", "archive"]
|
||||||
|
}
|
||||||
@ -1,22 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/app/globals.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "slate",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils"
|
||||||
"ui": "@/components/ui",
|
}
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"registries": {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
frontend/eslint.config.js
Normal 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
@ -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>
|
||||||
7158
frontend/package-lock.json
generated
@ -1,44 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "temp_init",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_NO_WARNINGS=1 next dev -p 3001",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "next start",
|
"lint": "eslint .",
|
||||||
"lint": "eslint"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@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-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@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-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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.5.1",
|
"cmdk": "^1.1.1",
|
||||||
"github-markdown-css": "^5.8.1",
|
"lucide-react": "^0.554.0",
|
||||||
"lucide-react": "^0.545.0",
|
"react": "^19.2.0",
|
||||||
"next": "15.5.5",
|
"react-dom": "^19.2.0",
|
||||||
"react": "19.1.0",
|
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"react-markdown": "^10.1.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",
|
"remark-gfm": "^4.0.1",
|
||||||
"swr": "^2.3.6",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tailwindcss-typography": "^3.1.0",
|
||||||
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"@types/node": "^20",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/react": "^19",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react-dom": "^19",
|
"@types/react": "^19.2.5",
|
||||||
"eslint": "^9",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint-config-next": "15.5.5",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"tailwindcss": "^4",
|
"autoprefixer": "^10.4.22",
|
||||||
"tw-animate-css": "^1.4.0",
|
"eslint": "^9.39.1",
|
||||||
"typescript": "^5"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||