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
|
||||
container_name: fundamental-frontend
|
||||
working_dir: /workspace/frontend
|
||||
command: npm run dev
|
||||
command: ["/workspace/frontend/scripts/docker-dev-entrypoint.sh"]
|
||||
environment:
|
||||
# 让 Next 的 API 路由代理到新的 api-gateway
|
||||
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
|
||||
|
||||
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.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
73
frontend/README.md
Normal file
@ -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
|
||||
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
|
||||
|
||||
async rewrites() {
|
||||
async rewrites() {
|
||||
const apiUrl = process.env.API_GATEWAY_URL || 'http://api-gateway:4000';
|
||||
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 && (
|
||||
<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="space-y-2">
|
||||
<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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Info } from 'lucide-react';
|
||||
import { TaskStatus } from '@/types/workflow';
|
||||
import { TaskInfo } from '@/hooks/useWorkflow';
|
||||
|
||||
interface FundamentalDataViewProps {
|
||||
taskStates: Record<string, TaskStatus>;
|
||||
taskInfos: Record<string, TaskInfo>;
|
||||
taskOutputs: Record<string, string>;
|
||||
}
|
||||
|
||||
export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalDataViewProps) {
|
||||
export function FundamentalDataView({ taskStates, taskInfos, taskOutputs }: FundamentalDataViewProps) {
|
||||
console.log('[FundamentalDataView] Render. States:', Object.keys(taskStates).length, taskStates);
|
||||
|
||||
// Filter tasks that look like data fetching tasks
|
||||
const dataTasks = Object.keys(taskStates).filter(taskId =>
|
||||
taskId.startsWith('fetch:') // Standardized task ID format: "fetch:provider_id"
|
||||
@ -28,6 +34,8 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
|
||||
{dataTasks.map(taskId => {
|
||||
const status = taskStates[taskId];
|
||||
const output = taskOutputs[taskId];
|
||||
const info = taskInfos[taskId];
|
||||
|
||||
// Dynamic name resolution: extract provider ID from task ID (e.g., "fetch:tushare" -> "Tushare")
|
||||
const providerId = taskId.replace('fetch:', '');
|
||||
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
||||
@ -38,17 +46,22 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
|
||||
<CardTitle className="text-lg font-medium truncate" title={providerName}>
|
||||
{providerName}
|
||||
</CardTitle>
|
||||
<StatusBadge status={status} />
|
||||
<StatusBadge status={status} message={info?.message} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 pt-2">
|
||||
<ScrollArea className="h-full w-full border rounded-md bg-muted/5 p-4">
|
||||
{output ? (
|
||||
{status === 'Failed' && info?.message ? (
|
||||
<div className="text-sm text-red-500 whitespace-pre-wrap break-words">
|
||||
<p className="font-semibold mb-1">Error:</p>
|
||||
{info.message}
|
||||
</div>
|
||||
) : output ? (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words text-foreground/80">
|
||||
{tryFormatJson(output)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm italic">
|
||||
{status === 'pending' || status === 'running' ? 'Waiting for data...' : 'No data returned'}
|
||||
{status === 'Pending' || status === 'Running' || status === 'Scheduled' ? 'Waiting for data...' : 'No data returned'}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@ -60,17 +73,48 @@ export function FundamentalDataView({ taskStates, taskOutputs }: FundamentalData
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: TaskStatus }) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="default" className="bg-green-600">Success</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Failed</Badge>;
|
||||
case 'running':
|
||||
return <Badge variant="secondary" className="animate-pulse text-blue-500">Fetching</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">Pending</Badge>;
|
||||
}
|
||||
function StatusBadge({ status, message }: { status: TaskStatus, message?: string }) {
|
||||
let badge = <Badge variant="outline">Pending</Badge>;
|
||||
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
badge = <Badge variant="default" className="bg-green-600 hover:bg-green-700">Success</Badge>;
|
||||
break;
|
||||
case 'Failed':
|
||||
badge = <Badge variant="destructive">Failed</Badge>;
|
||||
break;
|
||||
case 'Running':
|
||||
badge = <Badge variant="secondary" className="animate-pulse text-blue-500">Fetching</Badge>;
|
||||
break;
|
||||
case 'Scheduled':
|
||||
badge = <Badge variant="outline" className="text-yellow-600 border-yellow-200 bg-yellow-50">Scheduled</Badge>;
|
||||
break;
|
||||
case 'Skipped':
|
||||
badge = <Badge variant="outline" className="text-gray-500">Skipped</Badge>;
|
||||
break;
|
||||
default:
|
||||
badge = <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 cursor-help">
|
||||
{badge}
|
||||
<Info className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{message}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
function tryFormatJson(str: string): string {
|
||||
@ -87,4 +131,3 @@ function tryFormatJson(str: string): string {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,21 +56,30 @@ export function WorkflowReportLayout({
|
||||
.catch(err => console.error('Failed to load templates:', err));
|
||||
}, [selectedTemplateId]);
|
||||
|
||||
// Auto switch to analysis tab when workflow starts
|
||||
// Auto switch to analysis tab when workflow starts - DISABLED
|
||||
// User prefers to see Data Fetching first or stay on current tab
|
||||
/*
|
||||
useEffect(() => {
|
||||
if (workflow.status === 'connecting' || workflow.status === 'connected') {
|
||||
setActiveTab("analysis");
|
||||
}
|
||||
}, [workflow.status]);
|
||||
*/
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!selectedTemplateId) return;
|
||||
console.log('[WorkflowReportLayout] Start clicked for symbol:', symbol, 'Template:', selectedTemplateId);
|
||||
if (!selectedTemplateId) {
|
||||
console.warn('[WorkflowReportLayout] No template selected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WorkflowReportLayout] Calling startWorkflow...');
|
||||
const response = await workflow.startWorkflow({
|
||||
symbol,
|
||||
market: initialMarket,
|
||||
template_id: selectedTemplateId
|
||||
});
|
||||
console.log('[WorkflowReportLayout] startWorkflow returned:', response);
|
||||
|
||||
// Handle Symbol Normalization Redirection
|
||||
if (response && response.symbol && response.symbol !== symbol) {
|
||||
@ -171,6 +180,7 @@ export function WorkflowReportLayout({
|
||||
<TabsContent value="data" className="mt-4">
|
||||
<FundamentalDataView
|
||||
taskStates={workflow.taskStates}
|
||||
taskInfos={workflow.taskInfos}
|
||||
taskOutputs={workflow.taskOutputs}
|
||||
/>
|
||||
</TabsContent>
|
||||
@ -180,6 +190,7 @@ export function WorkflowReportLayout({
|
||||
{workflow.requestId ? (
|
||||
<AnalysisModulesView
|
||||
taskStates={workflow.taskStates}
|
||||
taskInfos={workflow.taskInfos}
|
||||
taskOutputs={workflow.taskOutputs}
|
||||
modulesConfig={dynamicModules}
|
||||
/>
|
||||
@ -272,4 +283,3 @@ function EmptyState({ onStart, message }: { onStart: () => void, message: string
|
||||
}
|
||||
|
||||
// Removed FinalReportView as it is now superseded by AnalysisModulesView
|
||||
|
||||
@ -120,7 +120,11 @@ export function TradingViewWidget({
|
||||
// TradingView 的 embed 脚本会在内部创建 iframe
|
||||
// 如果容器正在被卸载,或者 iframe 尚未完全准备好,可能会触发该错误
|
||||
// 我们只是 append script,实际的 iframe 是由 TradingView 脚本注入的
|
||||
container.appendChild(script);
|
||||
try {
|
||||
container.appendChild(script);
|
||||
} catch (appendError) {
|
||||
console.warn('[TradingView] Script append failed:', appendError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略偶发性 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() {
|
||||
const { setConfig, setError, setLoading } = useConfigStore();
|
||||
const { data, error, isLoading } = useSWR<SystemConfig>('/api/config', fetcher);
|
||||
const { data, error, isLoading } = useSWR<SystemConfig>('/api/config', fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(Boolean(isLoading));
|
||||
if (error) {
|
||||
setError(error.message || '加载配置失败');
|
||||
setError('加载配置失败');
|
||||
} else if (data) {
|
||||
setConfig(data);
|
||||
}
|
||||
@ -235,24 +238,8 @@ export function useConfig() {
|
||||
}
|
||||
|
||||
export async function updateConfig(payload: Partial<SystemConfig>) {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
const updated: SystemConfig = await res.json();
|
||||
// 同步到 store
|
||||
try {
|
||||
const { setConfig } = useConfigStore.getState();
|
||||
setConfig(updated);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return updated;
|
||||
// Deprecated
|
||||
throw new Error("updateConfig is deprecated. Use specific update functions.");
|
||||
}
|
||||
|
||||
export async function testConfig(type: string, data: any) {
|
||||
@ -273,7 +260,7 @@ export async function testConfig(type: string, data: any) {
|
||||
try {
|
||||
const err = JSON.parse(text);
|
||||
// 优先从标准字段中提取错误信息;同时分离 details
|
||||
let message: string = err?.error || err?.message || '';
|
||||
const message: string = err?.error || err?.message || '';
|
||||
let detailsStr = '';
|
||||
if (err?.details !== undefined) {
|
||||
if (typeof err.details === 'string') {
|
||||
@ -4,17 +4,27 @@ import {
|
||||
WorkflowDag,
|
||||
TaskStatus,
|
||||
StartWorkflowRequest,
|
||||
StartWorkflowResponse
|
||||
StartWorkflowResponse,
|
||||
TaskType
|
||||
} from '@/types/workflow';
|
||||
|
||||
export type WorkflowConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface TaskInfo {
|
||||
taskId: string;
|
||||
type?: TaskType;
|
||||
status: TaskStatus;
|
||||
message?: string;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
interface UseWorkflowReturn {
|
||||
// State
|
||||
status: WorkflowConnectionStatus;
|
||||
requestId: string | null;
|
||||
dag: WorkflowDag | null;
|
||||
taskStates: Record<string, TaskStatus>;
|
||||
taskInfos: Record<string, TaskInfo>; // Added for rich metadata
|
||||
taskOutputs: Record<string, string>; // Accumulates streaming content
|
||||
error: string | null;
|
||||
finalResult: any | null;
|
||||
@ -31,6 +41,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
const [requestId, setRequestId] = useState<string | null>(null);
|
||||
const [dag, setDag] = useState<WorkflowDag | null>(null);
|
||||
const [taskStates, setTaskStates] = useState<Record<string, TaskStatus>>({});
|
||||
const [taskInfos, setTaskInfos] = useState<Record<string, TaskInfo>>({});
|
||||
const [taskOutputs, setTaskOutputs] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [finalResult, setFinalResult] = useState<any | null>(null);
|
||||
@ -50,25 +61,48 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
}, []);
|
||||
|
||||
const handleEvent = useCallback((eventData: WorkflowEvent) => {
|
||||
console.log('[useWorkflow] Handling event type:', eventData.type);
|
||||
switch (eventData.type) {
|
||||
case 'WorkflowStarted':
|
||||
console.log('[useWorkflow] WorkflowStarted. Nodes:', eventData.payload.task_graph.nodes.length);
|
||||
setDag(eventData.payload.task_graph);
|
||||
// Initialize states based on graph
|
||||
const initialStates: Record<string, TaskStatus> = {};
|
||||
const initialInfos: Record<string, TaskInfo> = {};
|
||||
eventData.payload.task_graph.nodes.forEach(node => {
|
||||
initialStates[node.id] = node.initial_status;
|
||||
initialInfos[node.id] = {
|
||||
taskId: node.id,
|
||||
type: node.type,
|
||||
status: node.initial_status,
|
||||
lastUpdate: eventData.payload.timestamp
|
||||
};
|
||||
});
|
||||
setTaskStates(initialStates);
|
||||
setTaskInfos(initialInfos);
|
||||
break;
|
||||
|
||||
case 'TaskStateChanged':
|
||||
const { task_id, status, message, timestamp, task_type } = eventData.payload;
|
||||
console.log(`[useWorkflow] TaskStateChanged: ${task_id} -> ${status}`);
|
||||
setTaskStates(prev => ({
|
||||
...prev,
|
||||
[eventData.payload.task_id]: eventData.payload.status
|
||||
[task_id]: status
|
||||
}));
|
||||
setTaskInfos(prev => ({
|
||||
...prev,
|
||||
[task_id]: {
|
||||
taskId: task_id,
|
||||
type: task_type,
|
||||
status: status,
|
||||
message: message || undefined, // normalize null to undefined
|
||||
lastUpdate: timestamp
|
||||
}
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'TaskStreamUpdate':
|
||||
// console.log(`[useWorkflow] StreamUpdate for ${eventData.payload.task_id}, len: ${eventData.payload.content_delta.length}`);
|
||||
setTaskOutputs(prev => ({
|
||||
...prev,
|
||||
[eventData.payload.task_id]: (prev[eventData.payload.task_id] || '') + eventData.payload.content_delta
|
||||
@ -76,9 +110,23 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
break;
|
||||
|
||||
case 'WorkflowStateSnapshot':
|
||||
console.log('[useWorkflow] Snapshot received. Tasks:', Object.keys(eventData.payload.tasks_status).length);
|
||||
// Restore full state
|
||||
setDag(eventData.payload.task_graph);
|
||||
setTaskStates(eventData.payload.tasks_status);
|
||||
|
||||
// Reconstruct basic infos from snapshot (Snapshot doesn't carry full history messages sadly,
|
||||
// but we can at least sync status)
|
||||
const syncedInfos: Record<string, TaskInfo> = {};
|
||||
Object.entries(eventData.payload.tasks_status).forEach(([tid, stat]) => {
|
||||
syncedInfos[tid] = {
|
||||
taskId: tid,
|
||||
status: stat,
|
||||
lastUpdate: eventData.payload.timestamp
|
||||
};
|
||||
});
|
||||
setTaskInfos(prev => ({...prev, ...syncedInfos})); // Merge to keep existing messages if we have them
|
||||
|
||||
// Restore outputs if present
|
||||
const outputs: Record<string, string> = {};
|
||||
Object.entries(eventData.payload.tasks_output).forEach(([k, v]) => {
|
||||
@ -88,11 +136,13 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
break;
|
||||
|
||||
case 'WorkflowCompleted':
|
||||
console.log('[useWorkflow] Workflow Completed');
|
||||
setFinalResult(eventData.payload.result_summary);
|
||||
disconnect(); // Close connection on completion
|
||||
break;
|
||||
|
||||
case 'WorkflowFailed':
|
||||
console.error('[useWorkflow] Workflow Failed:', eventData.payload.reason);
|
||||
setError(eventData.payload.reason);
|
||||
// We might want to keep connected or disconnect depending on if retry is possible
|
||||
// For now, treat fatal error as disconnect reason
|
||||
@ -105,7 +155,9 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
}, [disconnect]);
|
||||
|
||||
const connectToWorkflow = useCallback((id: string) => {
|
||||
console.log('[useWorkflow] connectToWorkflow called for ID:', id);
|
||||
if (eventSourceRef.current) {
|
||||
console.log('[useWorkflow] Closing existing EventSource');
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
@ -114,27 +166,36 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const es = new EventSource(`/api/workflow/events/${id}`);
|
||||
const url = `/api/workflow/events/${id}`;
|
||||
console.log('[useWorkflow] Creating new EventSource:', url);
|
||||
// IMPORTANT: Do NOT use Next.js rewrites for SSE. They buffer.
|
||||
// We use a direct API Route Handler (app/api/workflow/events/[requestId]/route.ts)
|
||||
// which explicitly disables buffering via headers.
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => {
|
||||
es.onopen = (e) => {
|
||||
console.log(`[useWorkflow] SSE onopen triggered. URL: ${url}`, e);
|
||||
setStatus('connected');
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
console.log('[useWorkflow] Raw SSE Message:', event.data);
|
||||
const data = JSON.parse(event.data) as WorkflowEvent;
|
||||
console.log('[useWorkflow] Parsed Event:', data.type, data);
|
||||
handleEvent(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse workflow event:', e);
|
||||
console.error('[useWorkflow] Failed to parse workflow event:', e, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = (e) => {
|
||||
console.error('Workflow SSE error:', e);
|
||||
console.error('[useWorkflow] Workflow SSE error:', e);
|
||||
// EventSource automatically retries, but we might want to handle it explicitly
|
||||
// For now, let's assume if readyState is CLOSED, it's a fatal error
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
console.log('[useWorkflow] SSE Closed permanently');
|
||||
setStatus('error');
|
||||
setError('Connection lost');
|
||||
es.close();
|
||||
@ -149,33 +210,41 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
}, [handleEvent]);
|
||||
|
||||
const startWorkflow = useCallback(async (params: StartWorkflowRequest) => {
|
||||
console.log('[useWorkflow] startWorkflow called with params:', params);
|
||||
setStatus('connecting');
|
||||
setError(null);
|
||||
setDag(null);
|
||||
setTaskStates({});
|
||||
setTaskInfos({});
|
||||
setTaskOutputs({});
|
||||
setFinalResult(null);
|
||||
|
||||
try {
|
||||
console.log('[useWorkflow] Sending POST /api/workflow/start...');
|
||||
const res = await fetch('/api/workflow/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
console.log('[useWorkflow] POST response status:', res.status);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({}));
|
||||
console.error('[useWorkflow] Start failed:', errorBody);
|
||||
throw new Error(errorBody.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data: StartWorkflowResponse = await res.json();
|
||||
console.log('[useWorkflow] Workflow started successfully. Response:', data);
|
||||
|
||||
// Start listening
|
||||
console.log('[useWorkflow] Initiating SSE connection for requestId:', data.request_id);
|
||||
connectToWorkflow(data.request_id);
|
||||
|
||||
return data; // Return response so UI can handle symbol normalization redirection
|
||||
|
||||
} catch (e) {
|
||||
console.error('[useWorkflow] Exception in startWorkflow:', e);
|
||||
setStatus('error');
|
||||
setError(e instanceof Error ? e.message : 'Failed to start workflow');
|
||||
return undefined;
|
||||
@ -196,6 +265,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
requestId,
|
||||
dag,
|
||||
taskStates,
|
||||
taskInfos,
|
||||
taskOutputs,
|
||||
error,
|
||||
finalResult,
|
||||
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 type { AnalysisTemplateSets, DataSourcesConfig, LlmProvidersConfig } from '@/types';
|
||||
|
||||
// 根据设计文档定义配置项的类型
|
||||
// 根据设计文档定义配置项的类型(兼容 Legacy / 新结构)
|
||||
export interface DatabaseConfig {
|
||||
url: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface NewApiConfig {
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
}
|
||||
|
||||
export interface DataSourceConfig {
|
||||
[key: string]: { api_key: string };
|
||||
api_key?: string | null;
|
||||
base_url?: string | null;
|
||||
provider_id?: string | null;
|
||||
provider_name?: string | null;
|
||||
model_count?: number;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
database: DatabaseConfig;
|
||||
new_api: NewApiConfig;
|
||||
data_sources: DataSourceConfig;
|
||||
database?: DatabaseConfig | null;
|
||||
new_api?: NewApiConfig | null;
|
||||
data_sources?: DataSourcesConfig;
|
||||
llm_providers?: LlmProvidersConfig;
|
||||
analysis_template_sets?: AnalysisTemplateSets;
|
||||
}
|
||||
|
||||
interface ConfigState {
|
||||
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",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
frontend/eslint.config.js
Normal file
@ -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",
|
||||
"version": "0.1.0",
|
||||
"name": "temp_init",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "NODE_NO_WARNINGS=1 next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.5.1",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "15.5.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.3.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"reactflow": "^11.11.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-typography": "^3.1.0",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||