Initial commit
This commit is contained in:
7239
frontend/package-lock.json
generated
Normal file
7239
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
frontend/package.json
Normal file
60
frontend/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "flexible-test-platform",
|
||||
"version": "1.0.0",
|
||||
"description": "柔性敏捷智能测试体系平台",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm run dev:renderer\" \"npm run electron:dev\"",
|
||||
"dev:main": "tsc -p src/main/tsconfig.json && tsc -p src/preload/tsconfig.json",
|
||||
"dev:renderer": "vite",
|
||||
"electron:dev": "npm run dev:main && wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"build:main": "tsc -p src/main/tsconfig.json",
|
||||
"build:renderer": "vite build",
|
||||
"start": "npm run build:main && cross-env NODE_ENV=production electron .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
"test-platform",
|
||||
"ai-assistant"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^28.1.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.5",
|
||||
"mammoth": "^1.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.flextest.platform",
|
||||
"productName": "柔性敏捷智能测试体系平台",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis"
|
||||
}
|
||||
}
|
||||
}
|
||||
75
frontend/src/main/main.ts
Normal file
75
frontend/src/main/main.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Electron 主进程入口
|
||||
* 柔性敏捷智能测试体系平台
|
||||
*/
|
||||
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createMainWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
title: '柔性敏捷智能测试体系平台',
|
||||
frame: false, // 无边框窗口,自定义标题栏
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 开发模式加载 Vite 开发服务器
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// 生产模式加载构建后的文件
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 窗口控制 IPC 处理
|
||||
ipcMain.on('window-minimize', () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow?.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('window-close', () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window-is-maximized', () => {
|
||||
return mainWindow?.isMaximized();
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createMainWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
25
frontend/src/main/tsconfig.json
Normal file
25
frontend/src/main/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "../../dist/main",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
31
frontend/src/preload/preload.ts
Normal file
31
frontend/src/preload/preload.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Electron Preload 脚本
|
||||
* 安全地暴露 API 给渲染进程
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 窗口控制
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||
|
||||
// 平台信息
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
// TypeScript 类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
platform: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
25
frontend/src/preload/tsconfig.json
Normal file
25
frontend/src/preload/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "../../dist/preload",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
19
frontend/src/renderer/App.css
Normal file
19
frontend/src/renderer/App.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
117
frontend/src/renderer/App.tsx
Normal file
117
frontend/src/renderer/App.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import TitleBar from './components/TitleBar/TitleBar';
|
||||
import Toolbar from './components/Toolbar/Toolbar';
|
||||
import Sidebar from './components/Sidebar/Sidebar';
|
||||
import MainPanel from './components/MainPanel/MainPanel';
|
||||
import AIPanel from './components/AIPanel/AIPanel';
|
||||
import './App.css';
|
||||
import { IntentActionHandlers, IntentActionStatus } from './types/intentActions';
|
||||
import { ExtractedStep } from './services/api';
|
||||
|
||||
// 功能模块类型定义
|
||||
export type ModuleType = 'intent' | 'task' | 'simulation' | 'code' | 'data' | 'knowledge';
|
||||
|
||||
const App: React.FC = () => {
|
||||
// 当前选中的功能模块
|
||||
const [activeModule, setActiveModule] = useState<ModuleType>('intent');
|
||||
|
||||
const [intentActions, setIntentActions] = useState<IntentActionHandlers | null>(null);
|
||||
const [intentActionStatus, setIntentActionStatus] = useState<IntentActionStatus>({
|
||||
isSaving: false,
|
||||
isGenerating: false,
|
||||
isChecking: false,
|
||||
});
|
||||
|
||||
// 从意图编制提取的步骤(共享给任务规划)
|
||||
const [extractedSteps, setExtractedSteps] = useState<ExtractedStep[]>([]);
|
||||
|
||||
// 当前选中的知识库
|
||||
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<string>('default');
|
||||
|
||||
// AI 面板是否最大化
|
||||
const [aiPanelMaximized, setAiPanelMaximized] = useState(false);
|
||||
|
||||
// AI 面板高度
|
||||
const [aiPanelHeight, setAiPanelHeight] = useState(250);
|
||||
|
||||
// 完成编制后跳转到任务规划
|
||||
const handleIntentComplete = useCallback((steps: ExtractedStep[]) => {
|
||||
setExtractedSteps(steps);
|
||||
setActiveModule('task');
|
||||
}, []);
|
||||
|
||||
const handleToolbarAction = useCallback((action: string) => {
|
||||
if (activeModule === 'intent') {
|
||||
switch (action) {
|
||||
case '新建意图':
|
||||
intentActions?.onNew?.();
|
||||
return;
|
||||
case '打开':
|
||||
intentActions?.onOpen?.();
|
||||
return;
|
||||
case '保存':
|
||||
intentActions?.onSave?.();
|
||||
return;
|
||||
case 'AI 生成':
|
||||
intentActions?.onGenerate?.();
|
||||
return;
|
||||
case 'AI 检查':
|
||||
intentActions?.onCheck?.();
|
||||
return;
|
||||
case '导出':
|
||||
intentActions?.onExport?.();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [activeModule, intentActions]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* 标题栏 */}
|
||||
<TitleBar />
|
||||
|
||||
{/* 二级功能快捷栏 */}
|
||||
<Toolbar
|
||||
activeModule={activeModule}
|
||||
onActionClick={handleToolbarAction}
|
||||
intentActionStatus={intentActionStatus}
|
||||
selectedKnowledgeBase={selectedKnowledgeBase}
|
||||
onKnowledgeBaseChange={setSelectedKnowledgeBase}
|
||||
/>
|
||||
|
||||
{/* 主体区域 */}
|
||||
<div className="app-body">
|
||||
{/* 左侧功能面板 */}
|
||||
<Sidebar
|
||||
activeModule={activeModule}
|
||||
onModuleChange={setActiveModule}
|
||||
/>
|
||||
|
||||
{/* 工作区 */}
|
||||
<div className="workspace">
|
||||
{/* 主窗口 */}
|
||||
<MainPanel
|
||||
activeModule={activeModule}
|
||||
aiPanelMaximized={aiPanelMaximized}
|
||||
onRegisterIntentActions={setIntentActions}
|
||||
onIntentStatusChange={setIntentActionStatus}
|
||||
onIntentComplete={handleIntentComplete}
|
||||
extractedSteps={extractedSteps}
|
||||
/>
|
||||
|
||||
{/* AI 交互窗口 */}
|
||||
<AIPanel
|
||||
height={aiPanelHeight}
|
||||
onHeightChange={setAiPanelHeight}
|
||||
maximized={aiPanelMaximized}
|
||||
onMaximizeChange={setAiPanelMaximized}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
200
frontend/src/renderer/components/AIPanel/AIPanel.css
Normal file
200
frontend/src/renderer/components/AIPanel/AIPanel.css
Normal file
@@ -0,0 +1,200 @@
|
||||
.ai-panel {
|
||||
background: var(--bg-darker);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-panel.maximized {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.ai-panel.minimized {
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
cursor: ns-resize;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.ai-panel-header {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-md);
|
||||
background: var(--bg-sidebar);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ai-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-panel-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.panel-control-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.panel-control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-panel-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ai-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-message.ai .message-content {
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-message.user .message-content {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-content.loading {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: loadingBounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes loadingBounce {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-input-area {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ai-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 40px;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.ai-send-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.ai-send-btn:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.ai-send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
200
frontend/src/renderer/components/AIPanel/AIPanel.tsx
Normal file
200
frontend/src/renderer/components/AIPanel/AIPanel.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import './AIPanel.css';
|
||||
|
||||
interface AIPanelProps {
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
maximized: boolean;
|
||||
onMaximizeChange: (maximized: boolean) => void;
|
||||
}
|
||||
|
||||
const AIPanel: React.FC<AIPanelProps> = ({
|
||||
height,
|
||||
onHeightChange,
|
||||
maximized,
|
||||
onMaximizeChange,
|
||||
}) => {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [messages, setMessages] = useState<{ role: 'user' | 'ai'; content: string }[]>([
|
||||
{ role: 'ai', content: '你好!我是 AI 助手,可以帮助你生成意图编制内容、检查内容完整性等。有什么需要帮助的吗?' },
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const resizeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 拖拽调整大小
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = height;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = startY - e.clientY;
|
||||
const newHeight = Math.max(150, Math.min(600, startHeight + delta));
|
||||
onHeightChange(newHeight);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: userMessage }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: data.content }]);
|
||||
} else {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: `错误: ${data.error || '请求失败'}` }]);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: '网络错误,请确保后端服务已启动。' }]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// 切换最小化
|
||||
const toggleMinimize = () => {
|
||||
if (maximized) {
|
||||
onMaximizeChange(false);
|
||||
}
|
||||
setIsMinimized(!isMinimized);
|
||||
};
|
||||
|
||||
// 切换最大化
|
||||
const toggleMaximize = () => {
|
||||
if (isMinimized) {
|
||||
setIsMinimized(false);
|
||||
}
|
||||
onMaximizeChange(!maximized);
|
||||
};
|
||||
|
||||
const panelHeight = maximized ? '100%' : isMinimized ? 36 : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`ai-panel ${maximized ? 'maximized' : ''} ${isMinimized ? 'minimized' : ''}`}
|
||||
style={{ height: panelHeight }}
|
||||
>
|
||||
{/* 拖拽条 */}
|
||||
{!maximized && !isMinimized && (
|
||||
<div
|
||||
ref={resizeRef}
|
||||
className="resize-handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题栏 */}
|
||||
<div className="ai-panel-header">
|
||||
<span className="ai-panel-title">AI 交互窗口</span>
|
||||
<div className="ai-panel-controls">
|
||||
<button
|
||||
className="panel-control-btn"
|
||||
onClick={toggleMinimize}
|
||||
title={isMinimized ? '展开' : '最小化'}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14">
|
||||
{isMinimized ? (
|
||||
<path d="M3 8h10M8 3v10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
) : (
|
||||
<rect x="3" y="7" width="10" height="2" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="panel-control-btn"
|
||||
onClick={toggleMaximize}
|
||||
title={maximized ? '还原' : '最大化'}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14">
|
||||
{maximized ? (
|
||||
<>
|
||||
<rect x="3" y="5" width="8" height="8" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
<path d="M5 5V3h8v8h-2" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<rect x="3" y="3" width="10" height="10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{!isMinimized && (
|
||||
<div className="ai-panel-content">
|
||||
<div className="ai-messages">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`ai-message ${msg.role}`}>
|
||||
<div className="message-avatar">
|
||||
{msg.role === 'ai' ? '🤖' : '👤'}
|
||||
</div>
|
||||
<div className="message-content selectable">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="ai-message ai">
|
||||
<div className="message-avatar">🤖</div>
|
||||
<div className="message-content loading">
|
||||
<span className="loading-dot"></span>
|
||||
<span className="loading-dot"></span>
|
||||
<span className="loading-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ai-input-area">
|
||||
<textarea
|
||||
className="ai-input selectable"
|
||||
placeholder="输入消息,按 Enter 发送..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
className="ai-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIPanel;
|
||||
454
frontend/src/renderer/components/IntentEditor/IntentEditor.css
Normal file
454
frontend/src/renderer/components/IntentEditor/IntentEditor.css
Normal file
@@ -0,0 +1,454 @@
|
||||
.intent-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-message {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-message.success {
|
||||
background: rgba(82, 196, 26, 0.2);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.editor-message.error {
|
||||
background: rgba(245, 34, 45, 0.2);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding: var(--spacing-sm);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-title:focus {
|
||||
border-color: var(--border-focus);
|
||||
background: var(--bg-darker);
|
||||
}
|
||||
|
||||
.editor-title-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.complete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.complete-btn:hover {
|
||||
background: linear-gradient(135deg, #73d13d 0%, #52c41a 100%);
|
||||
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4);
|
||||
}
|
||||
|
||||
.complete-btn svg {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.complete-btn.loading {
|
||||
background: linear-gradient(135deg, #666 0%, #555 100%);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.complete-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.view-mode-toggle {
|
||||
display: flex;
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.editor-body.mode-edit .editor-textarea,
|
||||
.editor-body.mode-preview .editor-markdown {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-body.mode-split .editor-textarea,
|
||||
.editor-body.mode-split .editor-markdown {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.editor-divider {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: var(--spacing-md);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-darker);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-markdown {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-darker);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.editor-markdown h1,
|
||||
.editor-markdown h2,
|
||||
.editor-markdown h3,
|
||||
.editor-markdown h4,
|
||||
.editor-markdown h5,
|
||||
.editor-markdown h6 {
|
||||
margin: 0.6em 0 0.4em;
|
||||
}
|
||||
|
||||
.editor-markdown p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor-markdown ul,
|
||||
.editor-markdown ol {
|
||||
padding-left: 2em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor-markdown li {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.editor-markdown blockquote {
|
||||
border-left: 4px solid var(--border-color);
|
||||
padding-left: 1em;
|
||||
margin: 0.5em 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor-markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor-markdown th,
|
||||
.editor-markdown td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.editor-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.editor-markdown pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.editor-markdown code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* AI 检查结果面板 */
|
||||
.check-result-panel {
|
||||
width: 320px;
|
||||
background: var(--bg-sidebar);
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.check-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.check-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.check-header .close-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.check-header .close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.check-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.check-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: 8px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.check-status.passed {
|
||||
background: rgba(82, 196, 26, 0.2);
|
||||
}
|
||||
|
||||
.check-status.failed {
|
||||
background: rgba(245, 34, 45, 0.2);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.check-status.passed .status-icon {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.check-status.failed .status-icon {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-score {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.check-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.issue-list,
|
||||
.suggestion-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.issue-list li,
|
||||
.suggestion-list li {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: var(--bg-darker);
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.issue-list li::before {
|
||||
content: '• ';
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.suggestion-list li::before {
|
||||
content: ' ';
|
||||
}
|
||||
|
||||
/* 源码定位高亮 - 点击预览中的文字,定位到编辑器中对应位置 */
|
||||
.editor-markdown [data-source-line] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.editor-markdown [data-source-line]:hover {
|
||||
background-color: rgba(24, 144, 255, 0.15);
|
||||
box-shadow: inset 4px 0 0 0 var(--primary-color);
|
||||
}
|
||||
|
||||
.editor-markdown [data-source-line]::after {
|
||||
content: "点击定位";
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--primary-color);
|
||||
opacity: 0;
|
||||
padding: 2px 6px;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.editor-markdown [data-source-line]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 可点击的预览区域样式 */
|
||||
.clickable-preview {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-preview h1,
|
||||
.clickable-preview h2,
|
||||
.clickable-preview h3,
|
||||
.clickable-preview h4,
|
||||
.clickable-preview h5,
|
||||
.clickable-preview h6,
|
||||
.clickable-preview p,
|
||||
.clickable-preview li,
|
||||
.clickable-preview blockquote,
|
||||
.clickable-preview pre,
|
||||
.clickable-preview td,
|
||||
.clickable-preview th {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.clickable-preview h1:hover,
|
||||
.clickable-preview h2:hover,
|
||||
.clickable-preview h3:hover,
|
||||
.clickable-preview h4:hover,
|
||||
.clickable-preview h5:hover,
|
||||
.clickable-preview h6:hover,
|
||||
.clickable-preview p:hover,
|
||||
.clickable-preview li:hover,
|
||||
.clickable-preview blockquote:hover,
|
||||
.clickable-preview pre:hover,
|
||||
.clickable-preview td:hover,
|
||||
.clickable-preview th:hover {
|
||||
background-color: rgba(24, 144, 255, 0.15);
|
||||
box-shadow: inset 4px 0 0 0 var(--primary-color);
|
||||
}
|
||||
465
frontend/src/renderer/components/IntentEditor/IntentEditor.tsx
Normal file
465
frontend/src/renderer/components/IntentEditor/IntentEditor.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import './IntentEditor.css';
|
||||
import { apiService, Intent, ExtractedStep } from '../../services/api';
|
||||
import { IntentActionHandlers, IntentActionStatus } from '../../types/intentActions';
|
||||
import * as mammoth from 'mammoth/mammoth.browser';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
interface IntentEditorProps {
|
||||
onRegisterActions?: (handlers: IntentActionHandlers | null) => void;
|
||||
onStatusChange?: (status: IntentActionStatus) => void;
|
||||
onComplete?: (steps: ExtractedStep[]) => void;
|
||||
}
|
||||
|
||||
const IntentEditor: React.FC<IntentEditorProps> = ({ onRegisterActions, onStatusChange, onComplete }) => {
|
||||
const [title, setTitle] = useState('新建意图编制');
|
||||
const [content, setContent] = useState('');
|
||||
const [currentIntent, setCurrentIntent] = useState<Intent | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<any>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('edit');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.({
|
||||
isSaving,
|
||||
isGenerating,
|
||||
isChecking,
|
||||
});
|
||||
}, [onStatusChange, isSaving, isGenerating, isChecking]);
|
||||
|
||||
// 显示消息
|
||||
const showMessage = (type: 'success' | 'error', text: string) => {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
// 保存意图
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) {
|
||||
showMessage('error', '请输入标题');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (currentIntent) {
|
||||
// 更新
|
||||
const updated = await apiService.updateIntent(currentIntent.id, { title, content });
|
||||
setCurrentIntent(updated);
|
||||
showMessage('success', '保存成功');
|
||||
} else {
|
||||
// 创建
|
||||
const created = await apiService.createIntent({ title, content });
|
||||
setCurrentIntent(created);
|
||||
showMessage('success', '创建成功');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存失败');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, content, currentIntent]);
|
||||
|
||||
// AI 生成内容
|
||||
const handleGenerate = useCallback(async () => {
|
||||
// 如果文本框有内容,则基于当前内容生成测试规划
|
||||
if (content.trim()) {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await apiService.generateTestPlan(content, title || '用户输入');
|
||||
|
||||
if (result.success) {
|
||||
setContent(result.content);
|
||||
showMessage('success', 'AI 测试规划生成完成');
|
||||
} else {
|
||||
showMessage('error', result.error || 'AI 生成失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '网络错误,请确保后端服务已启动');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
} else {
|
||||
// 如果文本框为空,使用原有的意图编制生成
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await apiService.generateIntentContent({
|
||||
test_type: '功能测试',
|
||||
test_target: title || '待定义的测试目标',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setContent(result.content);
|
||||
showMessage('success', 'AI 生成完成');
|
||||
} else {
|
||||
showMessage('error', result.error || 'AI 生成失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '网络错误,请确保后端服务已启动');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}
|
||||
}, [title, content]);
|
||||
|
||||
// AI 检查内容
|
||||
const handleCheck = useCallback(async () => {
|
||||
if (!content.trim()) {
|
||||
showMessage('error', '请先输入或生成内容');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const result = await apiService.checkContent({
|
||||
content,
|
||||
intent_id: currentIntent?.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setCheckResult(result.result);
|
||||
} else {
|
||||
showMessage('error', result.error || 'AI 检查失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '网络错误');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [content, currentIntent]);
|
||||
|
||||
// 新建意图
|
||||
const handleNew = useCallback(() => {
|
||||
setTitle('新建意图编制');
|
||||
setContent('');
|
||||
setCurrentIntent(null);
|
||||
setCheckResult(null);
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const extractTitleAndContent = useCallback((text: string, fallbackTitle: string) => {
|
||||
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return { parsedTitle: fallbackTitle, parsedContent: text };
|
||||
}
|
||||
const first = lines[0];
|
||||
const parsedTitle = first.replace(/^#+\s*/, '') || fallbackTitle;
|
||||
const parsedContent = lines.slice(1).join('\n').trim();
|
||||
return { parsedTitle, parsedContent: parsedContent || text };
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||||
const ext = `.${file.name.split('.').pop() || ''}`.toLowerCase();
|
||||
|
||||
try {
|
||||
if (ext === '.docx') {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer: buffer });
|
||||
const fileContent = result.value || '';
|
||||
const { parsedContent } = extractTitleAndContent(
|
||||
fileContent,
|
||||
fileName || '新建意图编制'
|
||||
);
|
||||
setTitle(fileName || '新建意图编制');
|
||||
setContent(parsedContent);
|
||||
setCurrentIntent(null);
|
||||
setCheckResult(null);
|
||||
showMessage('success', '文件已打开');
|
||||
} else if (['.pdf', '.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff', '.webp'].includes(ext)) {
|
||||
const parsed = await apiService.parseIntentFile(file);
|
||||
setTitle(fileName || '新建意图编制');
|
||||
setContent(parsed.content || '');
|
||||
setCurrentIntent(null);
|
||||
setCheckResult(null);
|
||||
showMessage('success', '文件已解析并打开');
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const fileContent = typeof reader.result === 'string' ? reader.result : '';
|
||||
const { parsedContent } = extractTitleAndContent(
|
||||
fileContent,
|
||||
fileName || '新建意图编制'
|
||||
);
|
||||
setTitle(fileName || '新建意图编制');
|
||||
setContent(parsedContent);
|
||||
setCurrentIntent(null);
|
||||
setCheckResult(null);
|
||||
showMessage('success', '文件已打开');
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showMessage('error', '文件读取失败');
|
||||
};
|
||||
reader.readAsText(file, 'utf-8');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '文件解析失败';
|
||||
showMessage('error', errorMessage);
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
showMessage('error', '没有可导出的内容');
|
||||
return;
|
||||
}
|
||||
|
||||
const exportContent = `# ${title || '未命名意图'}\n\n${content || ''}`;
|
||||
const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const safeTitle = (title || 'intent').replace(/[\\/:*?"<>|]/g, '_');
|
||||
link.href = url;
|
||||
link.download = `${safeTitle}.md`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showMessage('success', '导出完成');
|
||||
}, [title, content]);
|
||||
|
||||
const isMarkdownContent = useCallback((text: string) => {
|
||||
return /(^|\n)#{1,6}\s+|\|.+\||```|\*\*|__|\[.+\]\(.+\)|^\s*[-*+]\s+/m.test(text);
|
||||
}, []);
|
||||
|
||||
// 点击预览区域时,通过搜索文本内容来定位到源码
|
||||
const handlePreviewClick = useCallback((event: React.MouseEvent) => {
|
||||
if (!textareaRef.current || viewMode === 'preview') return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
// 获取点击元素的纯文本内容
|
||||
const clickedText = target.textContent?.trim();
|
||||
if (!clickedText || clickedText.length < 2) return;
|
||||
|
||||
// 在源码中搜索这段文本
|
||||
const searchText = clickedText.slice(0, Math.min(clickedText.length, 50)); // 取前50字符搜索
|
||||
const index = content.indexOf(searchText);
|
||||
|
||||
if (index !== -1) {
|
||||
const textarea = textareaRef.current;
|
||||
const endIndex = index + searchText.length;
|
||||
|
||||
// 方法:按字符位置的比例来计算滚动位置
|
||||
// 这比依赖行高更可靠
|
||||
const totalLength = content.length;
|
||||
const ratio = index / totalLength;
|
||||
const maxScroll = textarea.scrollHeight - textarea.clientHeight;
|
||||
|
||||
// 目标位置,稍微往上偏移一点让选中内容在视口中间偏上
|
||||
const targetScrollTop = Math.max(0, ratio * textarea.scrollHeight - textarea.clientHeight / 3);
|
||||
|
||||
// 设置滚动位置
|
||||
textarea.scrollTop = Math.min(targetScrollTop, maxScroll);
|
||||
|
||||
// 使用 setTimeout 确保滚动完成后再设置选区和焦点
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(index, endIndex);
|
||||
|
||||
// 如果浏览器自动调整了滚动位置,我们再修正一次
|
||||
const currentScroll = textarea.scrollTop;
|
||||
const expectedScroll = Math.min(targetScrollTop, maxScroll);
|
||||
if (Math.abs(currentScroll - expectedScroll) > 50) {
|
||||
textarea.scrollTop = expectedScroll;
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}, [content, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterActions?.({
|
||||
onNew: handleNew,
|
||||
onOpen: handleOpen,
|
||||
onSave: handleSave,
|
||||
onGenerate: handleGenerate,
|
||||
onCheck: handleCheck,
|
||||
onExport: handleExport,
|
||||
});
|
||||
|
||||
return () => {
|
||||
onRegisterActions?.(null);
|
||||
};
|
||||
}, [onRegisterActions, handleNew, handleOpen, handleSave, handleGenerate, handleCheck, handleExport]);
|
||||
|
||||
return (
|
||||
<div className="intent-editor">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt,.markdown,.docx,.pdf,.png,.jpg,.jpeg,.bmp,.tif,.tiff,.webp"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<div className={`editor-message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑区域 */}
|
||||
<div className="editor-content">
|
||||
<div className="editor-main">
|
||||
<div className="editor-title-row">
|
||||
<input
|
||||
type="text"
|
||||
className="editor-title selectable"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="请输入意图标题..."
|
||||
/>
|
||||
<div className="editor-title-actions">
|
||||
{isMarkdownContent(content) && (
|
||||
<div className="view-mode-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${viewMode === 'edit' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('edit')}
|
||||
title="仅编辑"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${viewMode === 'split' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('split')}
|
||||
title="分屏预览"
|
||||
>
|
||||
分屏
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${viewMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('preview')}
|
||||
title="仅预览"
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`complete-btn ${isExtracting ? 'loading' : ''}`}
|
||||
onClick={async () => {
|
||||
if (!content.trim()) {
|
||||
showMessage('error', '请先输入或生成意图编制内容');
|
||||
return;
|
||||
}
|
||||
setIsExtracting(true);
|
||||
try {
|
||||
const result = await apiService.extractSteps(content, title);
|
||||
if (result.success && result.steps.length > 0) {
|
||||
showMessage('success', `已提取 ${result.steps.length} 个测试步骤`);
|
||||
onComplete?.(result.steps);
|
||||
} else {
|
||||
showMessage('error', result.error || '未能提取到测试步骤');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '网络错误,请确保后端服务已启动');
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}}
|
||||
disabled={isExtracting}
|
||||
title="完成编制并提交到下一流程"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<span className="loading-spinner" />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
)}
|
||||
{isExtracting ? '提取中...' : '完成编制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`editor-body mode-${viewMode}`}>
|
||||
{(viewMode === 'edit' || viewMode === 'split') && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor-textarea selectable"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="请输入意图编制内容,或使用 AI 生成..."
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'split' && <div className="editor-divider" />}
|
||||
{(viewMode === 'preview' || viewMode === 'split') && (
|
||||
<div
|
||||
className="editor-markdown selectable clickable-preview"
|
||||
onClick={handlePreviewClick}
|
||||
title="点击文字可定位到左侧编辑器对应位置"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
>
|
||||
{content || '暂无预览内容'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 检查结果面板 */}
|
||||
{checkResult && (
|
||||
<div className="check-result-panel">
|
||||
<div className="check-header">
|
||||
<span className="check-title">AI 检查结果</span>
|
||||
<button className="close-btn" onClick={() => setCheckResult(null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="check-body">
|
||||
<div className={`check-status ${checkResult.passed ? 'passed' : 'failed'}`}>
|
||||
<span className="status-icon">{checkResult.passed ? '✓' : '✗'}</span>
|
||||
<span className="status-text">
|
||||
{checkResult.passed ? '检查通过' : '需要改进'}
|
||||
</span>
|
||||
<span className="status-score">得分: {checkResult.score}/100</span>
|
||||
</div>
|
||||
|
||||
{checkResult.issues?.length > 0 && (
|
||||
<div className="check-section">
|
||||
<div className="section-title">发现的问题</div>
|
||||
<ul className="issue-list">
|
||||
{checkResult.issues.map((issue: string, idx: number) => (
|
||||
<li key={idx}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResult.suggestions?.length > 0 && (
|
||||
<div className="check-section">
|
||||
<div className="section-title">改进建议</div>
|
||||
<ul className="suggestion-list">
|
||||
{checkResult.suggestions.map((suggestion: string, idx: number) => (
|
||||
<li key={idx}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntentEditor;
|
||||
37
frontend/src/renderer/components/MainPanel/MainPanel.css
Normal file
37
frontend/src/renderer/components/MainPanel/MainPanel.css
Normal file
@@ -0,0 +1,37 @@
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
background: var(--bg-panel);
|
||||
overflow: hidden; /* Prevent double scrollbars, let children handle scroll */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-panel.minimized {
|
||||
flex: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.placeholder-panel h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.placeholder-panel p {
|
||||
font-size: 14px;
|
||||
}
|
||||
79
frontend/src/renderer/components/MainPanel/MainPanel.tsx
Normal file
79
frontend/src/renderer/components/MainPanel/MainPanel.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { ModuleType } from '../../App';
|
||||
import IntentEditor from '../IntentEditor/IntentEditor';
|
||||
import TaskPlanner from '../TaskPlanner/TaskPlanner';
|
||||
import './MainPanel.css';
|
||||
import { IntentActionHandlers, IntentActionStatus } from '../../types/intentActions';
|
||||
import { ExtractedStep } from '../../services/api';
|
||||
|
||||
interface MainPanelProps {
|
||||
activeModule: ModuleType;
|
||||
aiPanelMaximized: boolean;
|
||||
onRegisterIntentActions?: (handlers: IntentActionHandlers | null) => void;
|
||||
onIntentStatusChange?: (status: IntentActionStatus) => void;
|
||||
onIntentComplete?: (steps: ExtractedStep[]) => void;
|
||||
extractedSteps?: ExtractedStep[];
|
||||
}
|
||||
|
||||
// 模块标题映射
|
||||
const moduleTitles: Record<ModuleType, string> = {
|
||||
intent: '意图编制',
|
||||
task: '任务规划',
|
||||
simulation: '逻辑仿真',
|
||||
code: '代码生成',
|
||||
data: '数据服务',
|
||||
knowledge: '知识库',
|
||||
};
|
||||
|
||||
// 占位组件
|
||||
const PlaceholderPanel: React.FC<{ title: string }> = ({ title }) => (
|
||||
<div className="placeholder-panel">
|
||||
<div className="placeholder-icon">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{title}</h2>
|
||||
<p>功能开发中,敬请期待...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MainPanel: React.FC<MainPanelProps> = ({
|
||||
activeModule,
|
||||
aiPanelMaximized,
|
||||
onRegisterIntentActions,
|
||||
onIntentStatusChange,
|
||||
onIntentComplete,
|
||||
extractedSteps = [],
|
||||
}) => {
|
||||
// 渲染占位组件(仅当前激活且非intent/task时)
|
||||
const renderPlaceholder = () => {
|
||||
if (activeModule === 'intent' || activeModule === 'task') {
|
||||
return null;
|
||||
}
|
||||
return <PlaceholderPanel title={moduleTitles[activeModule]} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`main-panel ${aiPanelMaximized ? 'minimized' : ''}`}>
|
||||
{/* IntentEditor 始终挂载,通过 CSS 控制显示/隐藏以保持状态 */}
|
||||
<div style={{ display: activeModule === 'intent' ? 'contents' : 'none' }}>
|
||||
<IntentEditor
|
||||
onRegisterActions={onRegisterIntentActions}
|
||||
onStatusChange={onIntentStatusChange}
|
||||
onComplete={onIntentComplete}
|
||||
/>
|
||||
</div>
|
||||
{/* TaskPlanner 始终挂载 */}
|
||||
<div style={{ display: activeModule === 'task' ? 'contents' : 'none' }}>
|
||||
<TaskPlanner extractedSteps={extractedSteps} />
|
||||
</div>
|
||||
{/* 其他占位模块 */}
|
||||
{renderPlaceholder()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPanel;
|
||||
65
frontend/src/renderer/components/Sidebar/Sidebar.css
Normal file
65
frontend/src/renderer/components/Sidebar/Sidebar.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sidebar-item.active::before {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.sidebar-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: keep-all;
|
||||
}
|
||||
97
frontend/src/renderer/components/Sidebar/Sidebar.tsx
Normal file
97
frontend/src/renderer/components/Sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { ModuleType } from '../../App';
|
||||
import './Sidebar.css';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: ModuleType;
|
||||
onModuleChange: (module: ModuleType) => void;
|
||||
}
|
||||
|
||||
// 功能模块配置
|
||||
const modules: { id: ModuleType; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'intent',
|
||||
label: '意图编制',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'task',
|
||||
label: '任务规划',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'simulation',
|
||||
label: '逻辑仿真',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
label: '代码生成',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
label: '数据服务',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
label: '知识库',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ activeModule, onModuleChange }) => {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
{modules.map((module) => (
|
||||
<button
|
||||
key={module.id}
|
||||
className={`sidebar-item ${activeModule === module.id ? 'active' : ''}`}
|
||||
onClick={() => onModuleChange(module.id)}
|
||||
title={module.label}
|
||||
>
|
||||
<div className="sidebar-icon">{module.icon}</div>
|
||||
<span className="sidebar-label">{module.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
510
frontend/src/renderer/components/TaskPlanner/TaskPlanner.css
Normal file
510
frontend/src/renderer/components/TaskPlanner/TaskPlanner.css
Normal file
@@ -0,0 +1,510 @@
|
||||
.task-planner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.planner-message {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.planner-message.success {
|
||||
background: rgba(82, 196, 26, 0.2);
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.planner-message.error {
|
||||
background: rgba(245, 34, 45, 0.2);
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
/* 标题行 */
|
||||
.planner-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
background-color: #1a1a1a;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
}
|
||||
|
||||
.planner-title {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.planner-title:focus {
|
||||
border-color: #2563eb;
|
||||
background: #222;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.planner-title-row .complete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planner-title-row .complete-btn:hover {
|
||||
background: linear-gradient(135deg, #73d13d 0%, #52c41a 100%);
|
||||
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4);
|
||||
}
|
||||
|
||||
.planner-title-row .complete-btn svg {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 主体区域 - 三栏布局 */
|
||||
.planner-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 通用面板头部 */
|
||||
.panel-header {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
color: #e0e0e0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 空状态提示 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ========== 左侧:步骤列表 ========== */
|
||||
.planner-steps {
|
||||
width: 220px;
|
||||
min-width: 180px;
|
||||
background-color: #181818;
|
||||
border-right: 1px solid #2d2d2d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.step-item:hover {
|
||||
background-color: #2b2b2b;
|
||||
border-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.step-item.active {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-item.active .step-number {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 13px;
|
||||
color: #d4d4d4;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.step-item.active .step-name {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== 中间:一级标题列表 ========== */
|
||||
.planner-categories {
|
||||
width: 240px;
|
||||
min-width: 200px;
|
||||
background-color: #1a1a1a;
|
||||
border-right: 1px solid #2d2d2d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.categories-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.planner-categories .category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.planner-categories .category-item:hover {
|
||||
background-color: #2b2b2b;
|
||||
border-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.planner-categories .category-item.active {
|
||||
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
|
||||
border-color: #14b8a6;
|
||||
box-shadow: 0 2px 8px rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 14px;
|
||||
color: #d4d4d4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.planner-categories .category-item.active .category-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #2d2d2d;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.planner-categories .category-item.active .category-count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* ========== 右侧:参数详情 ========== */
|
||||
.planner-params {
|
||||
flex: 1;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.params-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 表头 */
|
||||
.params-table-header {
|
||||
display: flex;
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #cccccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 参数行 */
|
||||
.param-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
padding: 10px 16px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.param-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.param-row:hover {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
/* 列样式 */
|
||||
.planner-params .col-name {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding-right: 12px;
|
||||
color: #d4d4d4;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planner-params .col-desc {
|
||||
flex: 2;
|
||||
padding-right: 12px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planner-params .col-value {
|
||||
flex: 1.5;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.param-input {
|
||||
width: 100%;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3e3e42;
|
||||
color: #d4d4d4;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.param-input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.param-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ========== 分组面板样式 ========== */
|
||||
.group-panel {
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
background-color: #1e1e1e;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
background-color: #2d2d2d;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group-header:hover {
|
||||
background-color: #383838;
|
||||
}
|
||||
|
||||
.group-header::after {
|
||||
content: '▼';
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.group-panel.collapsed .group-header::after {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.group-body {
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.group-panel.collapsed .group-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 表头样式 */
|
||||
.attr-table-header {
|
||||
display: flex;
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 8px 15px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.attr-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
padding: 8px 15px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.attr-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.attr-row:hover {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
/* 列样式 */
|
||||
.col-name {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding-right: 10px;
|
||||
color: #d4d4d4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-desc {
|
||||
flex: 1.5;
|
||||
padding-right: 10px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-value {
|
||||
flex: 1.5;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.attr-input {
|
||||
width: 100%;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3e3e42;
|
||||
color: #d4d4d4;
|
||||
padding: 5px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.attr-input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.attr-input:disabled {
|
||||
background-color: #252526;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.attr-input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ========== 空状态垂直布局 ========== */
|
||||
.empty-state-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-vertical svg {
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-vertical .hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ========== 步骤计数和已配置标记 ========== */
|
||||
.step-count {
|
||||
font-weight: normal;
|
||||
color: #888;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.has-data-badge {
|
||||
font-size: 11px;
|
||||
color: #52c41a;
|
||||
background: rgba(82, 196, 26, 0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.planner-categories .category-item.has-params {
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
|
||||
.planner-categories .category-item.active.has-params {
|
||||
border-left-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.planner-categories .category-item.active .has-data-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* params-content 内的边距 */
|
||||
.params-content {
|
||||
padding: 12px;
|
||||
}
|
||||
315
frontend/src/renderer/components/TaskPlanner/TaskPlanner.tsx
Normal file
315
frontend/src/renderer/components/TaskPlanner/TaskPlanner.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './TaskPlanner.css';
|
||||
import { deviceParameters, DeviceCategory, AttributeGroup } from '../../data/deviceParameters';
|
||||
import { ExtractedStep } from '../../services/api';
|
||||
|
||||
interface TaskPlannerProps {
|
||||
extractedSteps?: ExtractedStep[];
|
||||
}
|
||||
|
||||
const TaskPlanner: React.FC<TaskPlannerProps> = ({ extractedSteps = [] }) => {
|
||||
// 任务标题
|
||||
const [taskTitle, setTaskTitle] = useState('新建任务规划');
|
||||
// 消息提示
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// 当前选中的步骤
|
||||
const [selectedStep, setSelectedStep] = useState<ExtractedStep | null>(null);
|
||||
|
||||
// 当前选中的设备分类
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceCategory | null>(
|
||||
deviceParameters.length > 0 ? deviceParameters[0] : null
|
||||
);
|
||||
|
||||
// 折叠状态
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
// 参数值状态(步骤ID -> 设备类别 -> 参数名 -> 值)
|
||||
const [paramValues, setParamValues] = useState<{
|
||||
[stepId: number]: {
|
||||
[deviceCategory: string]: {
|
||||
[paramName: string]: string;
|
||||
};
|
||||
};
|
||||
}>({});
|
||||
|
||||
// 过滤掉空值的参数对象
|
||||
const filterEmptyParams = (
|
||||
params: { [param: string]: string }
|
||||
): { [param: string]: string } => {
|
||||
const filtered: { [param: string]: string } = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'string' && value.trim().length > 0) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 深度合并两个设备参数对象(忽略空值和空对象)
|
||||
const mergeDeviceParams = (
|
||||
base: { [device: string]: { [param: string]: string } },
|
||||
overlay: { [device: string]: { [param: string]: string } }
|
||||
): { [device: string]: { [param: string]: string } } => {
|
||||
const result = { ...base };
|
||||
for (const deviceCategory of Object.keys(overlay)) {
|
||||
const overlayParams = overlay[deviceCategory];
|
||||
// 跳过空对象
|
||||
if (!overlayParams || Object.keys(overlayParams).length === 0) {
|
||||
continue;
|
||||
}
|
||||
// 过滤掉空值
|
||||
const filteredOverlay = filterEmptyParams(overlayParams);
|
||||
if (Object.keys(filteredOverlay).length === 0) {
|
||||
continue;
|
||||
}
|
||||
// 合并非空参数
|
||||
const baseParams = result[deviceCategory] || {};
|
||||
result[deviceCategory] = {
|
||||
...baseParams,
|
||||
...filteredOverlay
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 当 extractedSteps 变化时,初始化参数值(带累积继承)
|
||||
useEffect(() => {
|
||||
if (extractedSteps.length > 0) {
|
||||
// 初始化参数值,参数在步骤之间累积继承
|
||||
const initialValues: typeof paramValues = {};
|
||||
let accumulatedParams: { [device: string]: { [param: string]: string } } = {};
|
||||
|
||||
extractedSteps.forEach(step => {
|
||||
// 当前步骤的参数 = 之前累积的参数 + 当前步骤新增/修改的参数
|
||||
accumulatedParams = mergeDeviceParams(accumulatedParams, step.deviceParams);
|
||||
// 深拷贝累积参数到当前步骤
|
||||
initialValues[step.id] = JSON.parse(JSON.stringify(accumulatedParams));
|
||||
});
|
||||
|
||||
setParamValues(initialValues);
|
||||
|
||||
// 选中第一个步骤
|
||||
setSelectedStep(extractedSteps[0]);
|
||||
} else {
|
||||
setSelectedStep(null);
|
||||
setParamValues({});
|
||||
}
|
||||
}, [extractedSteps]);
|
||||
|
||||
const showMessage = (type: 'success' | 'error', text: string) => {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
// 选择步骤
|
||||
const handleStepSelect = (step: ExtractedStep) => {
|
||||
setSelectedStep(step);
|
||||
};
|
||||
|
||||
// 切换设备分类
|
||||
const handleDeviceSelect = (device: DeviceCategory) => {
|
||||
setSelectedDevice(device);
|
||||
setCollapsedGroups({});
|
||||
};
|
||||
|
||||
// 切换组折叠状态
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[groupName]: !prev[groupName]
|
||||
}));
|
||||
};
|
||||
|
||||
// 获取参数值
|
||||
const getParamValue = (attrName: string): string => {
|
||||
if (!selectedStep || !selectedDevice) return '';
|
||||
const stepParams = paramValues[selectedStep.id];
|
||||
if (!stepParams) return '';
|
||||
const deviceParams = stepParams[selectedDevice.category];
|
||||
if (!deviceParams) return '';
|
||||
return deviceParams[attrName] || '';
|
||||
};
|
||||
|
||||
// 更新参数值
|
||||
const updateParamValue = (attrName: string, value: string) => {
|
||||
if (!selectedStep || !selectedDevice) return;
|
||||
|
||||
setParamValues(prev => ({
|
||||
...prev,
|
||||
[selectedStep.id]: {
|
||||
...prev[selectedStep.id],
|
||||
[selectedDevice.category]: {
|
||||
...(prev[selectedStep.id]?.[selectedDevice.category] || {}),
|
||||
[attrName]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 检查当前步骤是否有该设备的非空参数
|
||||
const hasDeviceParams = (deviceCategory: string): boolean => {
|
||||
if (!selectedStep) return false;
|
||||
const stepParams = paramValues[selectedStep.id];
|
||||
if (!stepParams) return false;
|
||||
const deviceParams = stepParams[deviceCategory];
|
||||
if (!deviceParams || typeof deviceParams !== 'object') return false;
|
||||
|
||||
// 检查是否有任何非空值(排除空字符串、null、undefined)
|
||||
const values = Object.values(deviceParams);
|
||||
if (values.length === 0) return false;
|
||||
|
||||
return values.some(value => {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="task-planner">
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<div className={`planner-message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题行 */}
|
||||
<div className="planner-title-row">
|
||||
<input
|
||||
type="text"
|
||||
className="planner-title selectable"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
placeholder="请输入任务名称..."
|
||||
/>
|
||||
<button
|
||||
className="complete-btn"
|
||||
onClick={() => showMessage('success', '任务规划已完成')}
|
||||
title="完成规划并提交到下一流程"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
完成规划
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体区域 - 三栏布局 */}
|
||||
<div className="planner-body">
|
||||
{/* 左侧:动态步骤列表 */}
|
||||
<div className="planner-steps">
|
||||
<div className="panel-header">
|
||||
测试步骤
|
||||
{extractedSteps.length > 0 && (
|
||||
<span className="step-count">({extractedSteps.length})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="steps-list">
|
||||
{extractedSteps.length > 0 ? (
|
||||
extractedSteps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`step-item ${selectedStep?.id === step.id ? 'active' : ''}`}
|
||||
onClick={() => handleStepSelect(step)}
|
||||
>
|
||||
<span className="step-number">步骤 {step.id}</span>
|
||||
<span className="step-name">{step.name}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state-vertical">
|
||||
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
<path d="M9 12h6M9 16h6" />
|
||||
</svg>
|
||||
<span>暂无测试步骤</span>
|
||||
<span className="hint">请先在意图编制中完成编制</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:设备分类列表 */}
|
||||
<div className="planner-categories">
|
||||
<div className="panel-header">
|
||||
{selectedStep ? `仪器配置` : '仪器配置'}
|
||||
</div>
|
||||
<div className="categories-list">
|
||||
{deviceParameters.map((device) => (
|
||||
<div
|
||||
key={device.category}
|
||||
className={`category-item ${selectedDevice?.category === device.category ? 'active' : ''} ${hasDeviceParams(device.category) ? 'has-params' : ''}`}
|
||||
onClick={() => handleDeviceSelect(device)}
|
||||
>
|
||||
<span className="category-title">{device.category}</span>
|
||||
{hasDeviceParams(device.category) && (
|
||||
<span className="has-data-badge">已配置</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:参数详情 */}
|
||||
<div className="planner-params">
|
||||
{selectedDevice ? (
|
||||
<>
|
||||
<div className="panel-header">{selectedDevice.category}</div>
|
||||
<div className="params-content">
|
||||
{selectedDevice.groups.map((group) => {
|
||||
const isCollapsed = collapsedGroups[group.name];
|
||||
return (
|
||||
<div key={group.name} className={`group-panel ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div
|
||||
className="group-header"
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="group-body">
|
||||
{/* 表头 */}
|
||||
<div className="attr-table-header">
|
||||
<div className="col-name">属性名</div>
|
||||
<div className="col-desc">说明</div>
|
||||
<div className="col-value">属性值</div>
|
||||
</div>
|
||||
{/* 属性行 */}
|
||||
{group.attributes.map((attr) => (
|
||||
<div key={attr} className="attr-row">
|
||||
<div className="col-name">{attr}</div>
|
||||
<div className="col-desc">-</div>
|
||||
<div className="col-value">
|
||||
<input
|
||||
type="text"
|
||||
className="attr-input"
|
||||
value={getParamValue(attr)}
|
||||
onChange={(e) => updateParamValue(attr, e.target.value)}
|
||||
placeholder="请输入..."
|
||||
disabled={!selectedStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-header">参数详情</div>
|
||||
<div className="empty-state">请选择仪器类型</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPlanner;
|
||||
51
frontend/src/renderer/components/TitleBar/TitleBar.css
Normal file
51
frontend/src/renderer/components/TitleBar/TitleBar.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.titlebar {
|
||||
height: var(--titlebar-height);
|
||||
background: var(--bg-darker);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 46px;
|
||||
height: var(--titlebar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.control-btn.close-btn:hover {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
49
frontend/src/renderer/components/TitleBar/TitleBar.tsx
Normal file
49
frontend/src/renderer/components/TitleBar/TitleBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import './TitleBar.css';
|
||||
|
||||
const TitleBar: React.FC = () => {
|
||||
const handleMinimize = () => {
|
||||
window.electronAPI?.minimize();
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
window.electronAPI?.maximize();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.electronAPI?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="titlebar draggable">
|
||||
<div className="titlebar-left">
|
||||
<div className="logo">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="titlebar-title">柔性敏捷智能测试体系平台</span>
|
||||
</div>
|
||||
|
||||
<div className="titlebar-controls no-drag">
|
||||
<button className="control-btn" onClick={handleMinimize} title="最小化">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12">
|
||||
<rect x="3" y="7" width="10" height="2" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="control-btn" onClick={handleMaximize} title="最大化">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12">
|
||||
<rect x="3" y="3" width="10" height="10" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="control-btn close-btn" onClick={handleClose} title="关闭">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12">
|
||||
<path d="M3 3l10 10M13 3l-10 10" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
242
frontend/src/renderer/components/Toolbar/Toolbar.css
Normal file
242
frontend/src/renderer/components/Toolbar/Toolbar.css
Normal file
@@ -0,0 +1,242 @@
|
||||
.toolbar {
|
||||
height: var(--toolbar-height);
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
color: var(--border-color);
|
||||
margin: 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.toolbar-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-action:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-action.loading {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toolbar-action:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-action.primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-action.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.toolbar-action.ai {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-action.ai:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.toolbar-dropdown {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toolbar-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 120px;
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.35);
|
||||
z-index: 10;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.toolbar-menu-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: rgba(255, 255, 255, 0.9);
|
||||
animation: toolbar-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes toolbar-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.toolbar-status {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 知识库选择器 */
|
||||
.toolbar-kb-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-kb-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-kb-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-kb-btn svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.kb-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-kb-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kb-menu-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.toolbar-kb-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-kb-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-kb-item.active {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.kb-item-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kb-item-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-kb-item.active .kb-item-desc {
|
||||
color: rgba(24, 144, 255, 0.7);
|
||||
}
|
||||
|
||||
.kb-item-check {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
257
frontend/src/renderer/components/Toolbar/Toolbar.tsx
Normal file
257
frontend/src/renderer/components/Toolbar/Toolbar.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ModuleType } from '../../App';
|
||||
import './Toolbar.css';
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
activeModule: ModuleType;
|
||||
onActionClick?: (action: string) => void;
|
||||
intentActionStatus?: {
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
isChecking: boolean;
|
||||
};
|
||||
selectedKnowledgeBase?: string;
|
||||
onKnowledgeBaseChange?: (kbId: string) => void;
|
||||
}
|
||||
|
||||
// 模块对应的快捷操作
|
||||
const moduleActions: Record<ModuleType, { label: string; actions: string[] }> = {
|
||||
intent: {
|
||||
label: '意图编制',
|
||||
actions: ['开始', 'AI 生成', 'AI 检查', '保存', '导出'],
|
||||
},
|
||||
task: {
|
||||
label: '任务规划',
|
||||
actions: ['新建任务', '分配资源', '查看进度', '导出报告'],
|
||||
},
|
||||
simulation: {
|
||||
label: '逻辑仿真',
|
||||
actions: ['新建仿真', '运行仿真', '查看结果', '参数配置'],
|
||||
},
|
||||
code: {
|
||||
label: '代码生成',
|
||||
actions: ['生成代码', '选择模板', '配置参数', '预览'],
|
||||
},
|
||||
data: {
|
||||
label: '数据服务',
|
||||
actions: ['数据查询', '数据导入', '数据导出', '数据统计'],
|
||||
},
|
||||
knowledge: {
|
||||
label: '知识库',
|
||||
actions: ['搜索知识', '添加知识', '知识分类', '知识图谱'],
|
||||
},
|
||||
};
|
||||
|
||||
// 模拟知识库列表(后续可从后端获取)
|
||||
const knowledgeBases: KnowledgeBase[] = [
|
||||
{ id: 'default', name: '默认知识库', description: '系统默认知识库' },
|
||||
{ id: 'test-specs', name: '测试规范库', description: '测试标准和规范' },
|
||||
{ id: 'device-params', name: '设备参数库', description: '仪器设备参数' },
|
||||
{ id: 'historical', name: '历史案例库', description: '历史测试案例' },
|
||||
];
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
activeModule,
|
||||
onActionClick,
|
||||
intentActionStatus,
|
||||
selectedKnowledgeBase = 'default',
|
||||
onKnowledgeBaseChange
|
||||
}) => {
|
||||
const currentModule = moduleActions[activeModule];
|
||||
const [isStartMenuOpen, setIsStartMenuOpen] = useState(false);
|
||||
const [isKbMenuOpen, setIsKbMenuOpen] = useState(false);
|
||||
const startMenuRef = useRef<HTMLDivElement>(null);
|
||||
const kbMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedKb = knowledgeBases.find(kb => kb.id === selectedKnowledgeBase) || knowledgeBases[0];
|
||||
const isActionLoading = (action: string) => {
|
||||
if (action === '保存') return intentActionStatus?.isSaving ?? false;
|
||||
if (action === 'AI 生成') return intentActionStatus?.isGenerating ?? false;
|
||||
if (action === 'AI 检查') return intentActionStatus?.isChecking ?? false;
|
||||
return false;
|
||||
};
|
||||
|
||||
const getActionLabel = (action: string) => {
|
||||
if (action === '保存' && intentActionStatus?.isSaving) return '保存中...';
|
||||
if (action === 'AI 生成' && intentActionStatus?.isGenerating) return 'AI 生成中...';
|
||||
if (action === 'AI 检查' && intentActionStatus?.isChecking) return 'AI 检查中...';
|
||||
return action;
|
||||
};
|
||||
|
||||
const renderActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case '新建意图':
|
||||
case '开始':
|
||||
case '新建任务':
|
||||
case '新建仿真':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
case '保存':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
);
|
||||
case 'AI 生成':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
case 'AI 检查':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
);
|
||||
case '导出':
|
||||
case '导出报告':
|
||||
case '数据导出':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 3v12" />
|
||||
<polyline points="8 11 12 15 16 11" />
|
||||
<path d="M4 21h16" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isStartMenuOpen && startMenuRef.current && !startMenuRef.current.contains(event.target as Node)) {
|
||||
setIsStartMenuOpen(false);
|
||||
}
|
||||
if (isKbMenuOpen && kbMenuRef.current && !kbMenuRef.current.contains(event.target as Node)) {
|
||||
setIsKbMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isStartMenuOpen, isKbMenuOpen]);
|
||||
const getActionClassName = (action: string) => {
|
||||
if (action === '保存') {
|
||||
return 'toolbar-action primary';
|
||||
}
|
||||
if (action === 'AI 生成' || action === 'AI 检查') {
|
||||
return 'toolbar-action ai';
|
||||
}
|
||||
return 'toolbar-action';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-left">
|
||||
<span className="toolbar-label">{currentModule.label}</span>
|
||||
<span className="toolbar-separator">|</span>
|
||||
{currentModule.actions.map((action, index) => {
|
||||
if (action === '开始') {
|
||||
return (
|
||||
<div key={index} className="toolbar-dropdown" ref={startMenuRef}>
|
||||
<button
|
||||
className="toolbar-action"
|
||||
onClick={() => setIsStartMenuOpen((prev) => !prev)}
|
||||
>
|
||||
{renderActionIcon(action)}
|
||||
开始
|
||||
<span className="dropdown-caret">▾</span>
|
||||
</button>
|
||||
{isStartMenuOpen && (
|
||||
<div className="toolbar-menu">
|
||||
<button
|
||||
className="toolbar-menu-item"
|
||||
onClick={() => {
|
||||
setIsStartMenuOpen(false);
|
||||
onActionClick?.('新建意图');
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-menu-item"
|
||||
onClick={() => {
|
||||
setIsStartMenuOpen(false);
|
||||
onActionClick?.('打开');
|
||||
}}
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`${getActionClassName(action)} ${isActionLoading(action) ? 'loading' : ''}`}
|
||||
onClick={() => onActionClick?.(action)}
|
||||
disabled={isActionLoading(action)}
|
||||
>
|
||||
{renderActionIcon(action)}
|
||||
{isActionLoading(action) && <span className="toolbar-spinner" />}
|
||||
{getActionLabel(action)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<div className="toolbar-kb-selector" ref={kbMenuRef}>
|
||||
<button
|
||||
className="toolbar-kb-btn"
|
||||
onClick={() => setIsKbMenuOpen(prev => !prev)}
|
||||
title={selectedKb.description}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
<span className="kb-name">{selectedKb.name}</span>
|
||||
<span className="dropdown-caret">▾</span>
|
||||
</button>
|
||||
{isKbMenuOpen && (
|
||||
<div className="toolbar-kb-menu">
|
||||
<div className="kb-menu-header">选择知识库</div>
|
||||
{knowledgeBases.map(kb => (
|
||||
<button
|
||||
key={kb.id}
|
||||
className={`toolbar-kb-item ${kb.id === selectedKnowledgeBase ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onKnowledgeBaseChange?.(kb.id);
|
||||
setIsKbMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="kb-item-name">{kb.name}</span>
|
||||
{kb.description && <span className="kb-item-desc">{kb.description}</span>}
|
||||
{kb.id === selectedKnowledgeBase && <span className="kb-item-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
358
frontend/src/renderer/data/deviceParameters.ts
Normal file
358
frontend/src/renderer/data/deviceParameters.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
export interface AttributeGroup {
|
||||
name: string;
|
||||
attributes: string[];
|
||||
}
|
||||
|
||||
export interface DeviceCategory {
|
||||
category: string;
|
||||
groups: AttributeGroup[];
|
||||
}
|
||||
|
||||
export const deviceParameters: DeviceCategory[] = [
|
||||
{
|
||||
"category": "程控电源参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "基本输出参数",
|
||||
"attributes": [
|
||||
"输出电压",
|
||||
"输出电流",
|
||||
"输出频率",
|
||||
"输出相位"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "保护功能参数",
|
||||
"attributes": [
|
||||
"过压保护OVP",
|
||||
"过流保护OCP",
|
||||
"过温保护 OTP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "工作模式参数",
|
||||
"attributes": [
|
||||
"恒压模式CV",
|
||||
"恒流模式 CC",
|
||||
"序列模式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "远程控制参数",
|
||||
"attributes": [
|
||||
"接口类型",
|
||||
"编程指令"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "三相电源特有参数",
|
||||
"attributes": [
|
||||
"接线模式",
|
||||
"相序",
|
||||
"单相控制"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "功率探头参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "测量基础参数",
|
||||
"attributes": [
|
||||
"频率范围",
|
||||
"功率量程",
|
||||
"测量单位"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "校准与补偿参数",
|
||||
"attributes": [
|
||||
"归零校准",
|
||||
"温度补偿",
|
||||
"校准因子"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "信号处理参数",
|
||||
"attributes": [
|
||||
"平均滤波器",
|
||||
"平均次数",
|
||||
"测量速度"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "触发与测量模式",
|
||||
"attributes": [
|
||||
"触发源",
|
||||
"测量模式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "物理与连接配置",
|
||||
"attributes": [
|
||||
"接口类型",
|
||||
"探头类型"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "频谱分析仪参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "频率参数",
|
||||
"attributes": [
|
||||
"中心频率",
|
||||
"扫宽Span",
|
||||
"起始/终止频率"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "电平参数",
|
||||
"attributes": [
|
||||
"参考电平",
|
||||
"射频衰减器"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "带宽参数",
|
||||
"attributes": [
|
||||
"分辨率带宽RBW",
|
||||
"视频带宽VBW"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "扫描与控制",
|
||||
"attributes": [
|
||||
"扫描时间",
|
||||
"扫描模式",
|
||||
"检波方式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "迹线与标记",
|
||||
"attributes": [
|
||||
"迹线模式",
|
||||
"标记功能"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "矢量网络分析仪参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "频率参数",
|
||||
"attributes": [
|
||||
"扫描类型线性/对数/分段",
|
||||
"起始终止频率或中心频率与扫宽",
|
||||
"扫描点数"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "激励与校频率参数功率参数",
|
||||
"attributes": [
|
||||
"输出功率电平",
|
||||
"平均与平滑"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "带宽与扫描参数",
|
||||
"attributes": [
|
||||
"中频带宽IFBW",
|
||||
"扫描速度"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "校准参数",
|
||||
"attributes": [
|
||||
"校准类型响应/全单端口/全双端口",
|
||||
"校准件类型"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "显示与数据参数",
|
||||
"attributes": [
|
||||
"测量参数S11/S21/S12/S22",
|
||||
"显示格式LogMag/Smith/Phase等",
|
||||
"标记Marker与限值线"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "矢量信号分析仪参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "频率参数",
|
||||
"attributes": [
|
||||
"中心频率",
|
||||
"扫宽/频距"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "幅度/电平参数",
|
||||
"attributes": [
|
||||
"参考电平",
|
||||
"输入衰减",
|
||||
"预放状态"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "带宽与采样参数",
|
||||
"attributes": [
|
||||
"分辨率带宽RBW",
|
||||
"测量带宽",
|
||||
"采样点数"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "解调与分析参数",
|
||||
"attributes": [
|
||||
"调制格式自动/手动",
|
||||
"符号率",
|
||||
"滤波器类型",
|
||||
"分析类型EVM/星座图等"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "触发与数据管理",
|
||||
"attributes": [
|
||||
"触发源与电平",
|
||||
"数据记录与导出"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "矢量信号源参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "频率与电平",
|
||||
"attributes": [
|
||||
"载波频率",
|
||||
"输出功率",
|
||||
"ALC自动电平控制"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "调制参数",
|
||||
"attributes": [
|
||||
"调制格式自动/手动",
|
||||
"滤波器类型",
|
||||
"符号率"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "基带信号",
|
||||
"attributes": [
|
||||
"数据源",
|
||||
"IQ调制器"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "扫描与序列",
|
||||
"attributes": [
|
||||
"扫描模式",
|
||||
"序列编辑"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "系统与校准",
|
||||
"attributes": [
|
||||
"系统连接",
|
||||
"校准"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "示波器参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "垂直系统",
|
||||
"attributes": [
|
||||
"垂直灵敏度Volts/Div",
|
||||
"垂直位置",
|
||||
"耦合方式AC/DC/GND"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "水平系统",
|
||||
"attributes": [
|
||||
"水平时基Time/Div",
|
||||
"水平位置",
|
||||
"滚动模式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "触发系统",
|
||||
"attributes": [
|
||||
"触发源",
|
||||
"触发电平",
|
||||
"触发模式Auto/Normal/Single",
|
||||
"触发类型达沿/脉宽/斜率"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "采样与存储",
|
||||
"attributes": [
|
||||
"采样率",
|
||||
"存储深度",
|
||||
"采集模式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "探头与输入",
|
||||
"attributes": [
|
||||
"探头衰减比",
|
||||
"输入阻抗"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "信号源基础参数",
|
||||
"groups": [
|
||||
{
|
||||
"name": "频率参数",
|
||||
"attributes": [
|
||||
"输出频率",
|
||||
"频率偏移",
|
||||
"相位"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "电平/幅度参数",
|
||||
"attributes": [
|
||||
"输出功率/幅度",
|
||||
"衰减器设置",
|
||||
"ALC自动电平控制"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "调制参数",
|
||||
"attributes": [
|
||||
"模拟调制AM/FM/PM",
|
||||
"数字调制ASK/PSK/QAM",
|
||||
"调制源内部/外部"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "扫描与触发",
|
||||
"attributes": [
|
||||
"扫描模式",
|
||||
"触发源"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "波形与数字特性",
|
||||
"attributes": [
|
||||
"波形选择",
|
||||
"码型与时钟"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
385
frontend/src/renderer/data/testSteps.ts
Normal file
385
frontend/src/renderer/data/testSteps.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
// 测试步骤数据结构定义
|
||||
|
||||
export interface StepParameter {
|
||||
name: string; // 属性名
|
||||
description: string; // 说明
|
||||
value: string; // 属性值
|
||||
}
|
||||
|
||||
export interface StepCategory {
|
||||
title: string; // 二级标题(如:直流电源、信号源)
|
||||
parameters: StepParameter[];
|
||||
}
|
||||
|
||||
export interface TestStep {
|
||||
id: number;
|
||||
name: string; // 步骤名称
|
||||
purpose: string; // 目的
|
||||
categories: StepCategory[];
|
||||
}
|
||||
|
||||
// 从意图编制提取的步骤(用于动态生成)
|
||||
export interface ExtractedStep {
|
||||
id: number;
|
||||
name: string; // 步骤名称(如:上电测试_电源初始化)
|
||||
purpose: string; // 步骤目的
|
||||
// 按设备分类的参数,key为设备类别(如"程控电源参数"、"频谱分析仪参数")
|
||||
deviceParams: {
|
||||
[deviceCategory: string]: {
|
||||
[paramName: string]: string; // 参数名 -> 参数值
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 提取步骤的API响应
|
||||
export interface ExtractStepsResponse {
|
||||
success: boolean;
|
||||
steps: ExtractedStep[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 空的初始步骤列表
|
||||
export const emptySteps: ExtractedStep[] = [];
|
||||
|
||||
// 示例测试步骤数据(基于用户提供的意图编制)
|
||||
export const testSteps: TestStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "上电测试_电源初始化",
|
||||
purpose: "初始化直流电源并验证OCP/OVP保护逻辑",
|
||||
categories: [
|
||||
{
|
||||
title: "直流电源",
|
||||
parameters: [
|
||||
{ name: "输出电压", description: "直流输出电压设置", value: "28V" },
|
||||
{ name: "输出电流", description: "直流输出电流限制", value: "6A" },
|
||||
{ name: "OCP阈值", description: "过流保护阈值", value: "6.1A" },
|
||||
{ name: "OVP阈值", description: "过压保护阈值", value: "31V" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "射频输出", description: "RF输出开关状态", value: "OFF" },
|
||||
{ name: "远程控制", description: "远程控制模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "远程控制", description: "远程控制模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "开关矩阵",
|
||||
parameters: [
|
||||
{ name: "远程控制", description: "远程控制模式", value: "ON" },
|
||||
{ name: "路径状态", description: "输出路径配置", value: "阻断所有路径" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "镜频测试_SC频段_通道切换",
|
||||
purpose: "验证SC频段信号通道的镜像抑制比",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-25dBm" },
|
||||
{ name: "输出模式", description: "信号输出模式", value: "CW模式" },
|
||||
{ name: "频率", description: "输出信号频率", value: "2.7GHz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "自动调整" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "开关矩阵",
|
||||
parameters: [
|
||||
{ name: "路径选择", description: "信号通道选择", value: "SC边" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "镜频测试_SC2.7GHz_P1测量",
|
||||
purpose: "测量SC频段2.7GHz主频信号功率P1",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-25dBm" },
|
||||
{ name: "频率", description: "输出信号频率", value: "2.7GHz" },
|
||||
{ name: "RF输出", description: "射频输出开关", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "2.7GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
{ name: "Detector", description: "检测器类型", value: "Peak" },
|
||||
{ name: "Sweeptime", description: "扫描时间", value: "1ms" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P1)" },
|
||||
{ name: "计算逻辑", description: "数据处理公式", value: "P1=FreqSpan[0].PowerValue" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "镜频测试_SC2.7GHz_P2测量",
|
||||
purpose: "测量SC频段3.88GHz镜像频信号功率P2",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-25dBm" },
|
||||
{ name: "频率", description: "输出信号频率(保持)", value: "2.7GHz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "3.88GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
{ name: "Detector", description: "检测器类型", value: "Peak" },
|
||||
{ name: "Sweeptime", description: "扫描时间", value: "1ms" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P2)" },
|
||||
{ name: "计算逻辑", description: "数据处理公式", value: "P2=FreqSpan[1].PowerValue" },
|
||||
{ name: "判定标准", description: "合格判定条件", value: "镜频抑制比(P1-P2) > 7dB" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "镜频测试_SC6.2GHz_P1测量",
|
||||
purpose: "测量SC频段6.2GHz主频信号功率P1",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-25dBm" },
|
||||
{ name: "频率", description: "输出信号频率", value: "6.2GHz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "6.2GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P1)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "镜频测试_SC6.2GHz_P2测量",
|
||||
purpose: "测量SC频段5.88GHz镜像频信号功率P2",
|
||||
categories: [
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "5.88GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P2)" },
|
||||
{ name: "判断逻辑", description: "数据处理", value: "镜频抑制比=P1-P2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "镜频测试_X频段_通道切换",
|
||||
purpose: "验证X频段信号通道的镜像抑制比",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-40dBm" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "开关矩阵",
|
||||
parameters: [
|
||||
{ name: "路径选择", description: "信号通道选择", value: "X边" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "镜频测试_X8GHz_P1测量",
|
||||
purpose: "测量X频段8GHz主频信号功率P1",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-40dBm" },
|
||||
{ name: "频率", description: "输出信号频率", value: "8GHz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "8GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P1)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "镜频测试_X8GHz_P2测量",
|
||||
purpose: "测量X频段11GHz镜像频信号功率P2",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-40dBm" },
|
||||
{ name: "频率", description: "输出信号频率(保持)", value: "8GHz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "11GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P2)" },
|
||||
{ name: "判断逻辑", description: "数据处理", value: "镜频抑制比=P1-P2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "镜频测试_X12GHz_P1测量",
|
||||
purpose: "测量X频段12GHz主频信号功率P1",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "RF Power", description: "射频功率输出", value: "-40dBm" },
|
||||
{ name: "频率", description: "输出信号频率", value: "12GHz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "12GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P1)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: "镜频测试_X12GHz_P2测量",
|
||||
purpose: "测量X频段15GHz镜像频信号功率P2",
|
||||
categories: [
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "中心频率", description: "频谱仪中心频率", value: "15GHz" },
|
||||
{ name: "Span", description: "频率跨度", value: "500MHz" },
|
||||
{ name: "MAXH", description: "最大保持模式", value: "ON" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "数据采集",
|
||||
parameters: [
|
||||
{ name: "读取项", description: "采集数据类型", value: "Max Peak功率(P2)" },
|
||||
{ name: "判断逻辑", description: "数据处理", value: "镜频抑制比=P1-P2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "断电测试_仪器资源回收",
|
||||
purpose: "断开所有设备供电并释放资源",
|
||||
categories: [
|
||||
{
|
||||
title: "信号源",
|
||||
parameters: [
|
||||
{ name: "射频输出", description: "RF输出开关状态", value: "OFF" },
|
||||
{ name: "远程控制", description: "远程控制模式", value: "OFF" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "频谱仪",
|
||||
parameters: [
|
||||
{ name: "远程控制", description: "远程控制模式", value: "OFF" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "开关矩阵",
|
||||
parameters: [
|
||||
{ name: "路径状态", description: "输出路径配置", value: "阻断所有路径" },
|
||||
{ name: "远程控制", description: "远程控制模式", value: "OFF" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "直流电源",
|
||||
parameters: [
|
||||
{ name: "输出状态", description: "电源输出开关", value: "关闭" },
|
||||
{ name: "降压速率", description: "电压下降速度限制", value: "≤5V/s" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
13
frontend/src/renderer/index.html
Normal file
13
frontend/src/renderer/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:*">
|
||||
<title>柔性敏捷智能测试体系平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
frontend/src/renderer/main.tsx
Normal file
10
frontend/src/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
199
frontend/src/renderer/services/api.ts
Normal file
199
frontend/src/renderer/services/api.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* API 服务层
|
||||
* 与后端 FastAPI 通信
|
||||
*/
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api';
|
||||
|
||||
// 类型定义
|
||||
export interface Intent {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IntentListResponse {
|
||||
total: number;
|
||||
items: Intent[];
|
||||
}
|
||||
|
||||
export interface GenerateRequest {
|
||||
prompt?: string;
|
||||
test_type?: string;
|
||||
test_target?: string;
|
||||
additional_requirements?: string;
|
||||
}
|
||||
|
||||
export interface GenerateResponse {
|
||||
content: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CheckRequest {
|
||||
content: string;
|
||||
intent_id?: number;
|
||||
requirements?: string[];
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
passed: boolean;
|
||||
score: number;
|
||||
issues: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface CheckResponse {
|
||||
result: CheckResult;
|
||||
record_id?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 提取的步骤参数
|
||||
export interface ExtractedStep {
|
||||
id: number;
|
||||
name: string;
|
||||
purpose: string;
|
||||
deviceParams: {
|
||||
[deviceCategory: string]: {
|
||||
[paramName: string]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtractStepsResponse {
|
||||
success: boolean;
|
||||
steps: ExtractedStep[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ParseFileResponse {
|
||||
filename: string;
|
||||
suffix: string;
|
||||
title: string;
|
||||
content: string;
|
||||
raw_result?: unknown;
|
||||
}
|
||||
|
||||
// API 方法
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const apiService = {
|
||||
// 意图 CRUD
|
||||
async listIntents(skip = 0, limit = 20): Promise<IntentListResponse> {
|
||||
return request(`/intent?skip=${skip}&limit=${limit}`);
|
||||
},
|
||||
|
||||
async getIntent(id: number): Promise<Intent> {
|
||||
return request(`/intent/${id}`);
|
||||
},
|
||||
|
||||
async createIntent(data: { title: string; content?: string }): Promise<Intent> {
|
||||
return request('/intent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async updateIntent(id: number, data: { title?: string; content?: string; status?: string }): Promise<Intent> {
|
||||
return request(`/intent/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteIntent(id: number): Promise<void> {
|
||||
await request(`/intent/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// AI 方法
|
||||
async generateIntentContent(data: GenerateRequest): Promise<GenerateResponse> {
|
||||
return request('/ai/generate-intent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async generateTestPlan(requirementText: string, sourceName?: string): Promise<GenerateResponse> {
|
||||
return request('/ai/generate-plan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
requirement_text: requirementText,
|
||||
source_name: sourceName || '用户输入'
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async generateContent(prompt: string, context?: string): Promise<GenerateResponse> {
|
||||
return request('/ai/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt, context }),
|
||||
});
|
||||
},
|
||||
|
||||
async checkContent(data: CheckRequest): Promise<CheckResponse> {
|
||||
return request('/ai/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// 从意图编制内容中提取测试步骤和参数
|
||||
async extractSteps(content: string, title?: string): Promise<ExtractStepsResponse> {
|
||||
return request('/ai/extract-steps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
title: title || '意图编制'
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async parseIntentFile(file: File): Promise<ParseFileResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/intent/parse-file`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const data = await response.json();
|
||||
detail = data?.detail ? `: ${data.detail}` : '';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}${detail}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
export default apiService;
|
||||
120
frontend/src/renderer/styles/global.css
Normal file
120
frontend/src/renderer/styles/global.css
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 全局样式
|
||||
* 柔性敏捷智能测试体系平台
|
||||
*/
|
||||
|
||||
:root {
|
||||
--primary-color: #1890ff;
|
||||
--primary-hover: #40a9ff;
|
||||
--bg-dark: #1e1e1e;
|
||||
--bg-darker: #161616;
|
||||
--bg-sidebar: #252526;
|
||||
--bg-panel: #1e1e1e;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #cccccc;
|
||||
--text-muted: #888888;
|
||||
--border-color: #3c3c3c;
|
||||
--border-focus: #007acc;
|
||||
--success-color: #52c41a;
|
||||
--warning-color: #faad14;
|
||||
--error-color: #f5222d;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
/* 尺寸 */
|
||||
--titlebar-height: 32px;
|
||||
--toolbar-height: 40px;
|
||||
--sidebar-width: 60px;
|
||||
--ai-panel-min-height: 150px;
|
||||
--ai-panel-default-height: 250px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-dark);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 文本可选择区域 */
|
||||
.selectable {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* 按钮通用样式 */
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 输入框通用样式 */
|
||||
input, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg-darker);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
/* 拖拽区域 */
|
||||
.draggable {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
14
frontend/src/renderer/types/intentActions.ts
Normal file
14
frontend/src/renderer/types/intentActions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface IntentActionHandlers {
|
||||
onNew?: () => void;
|
||||
onOpen?: () => void;
|
||||
onSave?: () => void;
|
||||
onGenerate?: () => void;
|
||||
onCheck?: () => void;
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
export interface IntentActionStatus {
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
isChecking: boolean;
|
||||
}
|
||||
47
frontend/tsconfig.json
Normal file
47
frontend/tsconfig.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/renderer/*"
|
||||
],
|
||||
"@components/*": [
|
||||
"src/renderer/components/*"
|
||||
],
|
||||
"@layouts/*": [
|
||||
"src/renderer/layouts/*"
|
||||
],
|
||||
"@services/*": [
|
||||
"src/renderer/services/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/renderer/**/*",
|
||||
"src/preload/**/*"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
frontend/tsconfig.node.json
Normal file
12
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
root: 'src/renderer',
|
||||
build: {
|
||||
outDir: '../../dist/renderer',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/renderer'),
|
||||
'@components': path.resolve(__dirname, 'src/renderer/components'),
|
||||
'@layouts': path.resolve(__dirname, 'src/renderer/layouts'),
|
||||
'@services': path.resolve(__dirname, 'src/renderer/services'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user