feat: Implement recipe selection with backend integration
Backend changes (C#): - Add SelectRecipe method to MachineBridge for recipe selection - Add currentRecipeId tracking in MainForm - Implement SELECT_RECIPE handler in WebSocketServer Frontend changes (React/TypeScript): - Add selectRecipe method to communication layer - Update handleSelectRecipe to call backend and handle response - Recipe selection updates ModelInfoPanel automatically - Add error handling and logging for recipe operations Layout improvements: - Add Layout component with persistent Header and Footer - Create separate IOMonitorPage for full-screen I/O monitoring - Add dynamic IO tab switcher in Header (Inputs/Outputs) - Ensure consistent UI across all pages 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -41,9 +41,25 @@ namespace HMIWeb
|
||||
_host.HandleCommand(command);
|
||||
}
|
||||
|
||||
public void LoadRecipe(string recipeId)
|
||||
public string SelectRecipe(string recipeId)
|
||||
{
|
||||
Console.WriteLine($"[C#] Loading Recipe: {recipeId}");
|
||||
Console.WriteLine($"[C#] Selecting Recipe: {recipeId}");
|
||||
|
||||
// In real app, load recipe settings here
|
||||
// For now, just return success
|
||||
try
|
||||
{
|
||||
// Simulate recipe loading logic
|
||||
_host.SetCurrentRecipe(recipeId);
|
||||
|
||||
var response = new { success = true, message = "Recipe selected successfully", recipeId = recipeId };
|
||||
return JsonConvert.SerializeObject(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var response = new { success = false, message = ex.Message };
|
||||
return JsonConvert.SerializeObject(response);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetConfig()
|
||||
@@ -93,5 +109,49 @@ namespace HMIWeb
|
||||
Console.WriteLine($"[Backend] Data: {configJson}");
|
||||
// In a real app, we would save this to a file or database
|
||||
}
|
||||
public string GetIOList()
|
||||
{
|
||||
var ioList = new System.Collections.Generic.List<object>();
|
||||
|
||||
// Outputs (0-31)
|
||||
for (int i = 0; i < 32; i++)
|
||||
{
|
||||
string name = $"DOUT_{i:D2}";
|
||||
if (i == 0) name = "Tower Lamp Red";
|
||||
if (i == 1) name = "Tower Lamp Yel";
|
||||
if (i == 2) name = "Tower Lamp Grn";
|
||||
|
||||
ioList.Add(new { id = i, name = name, type = "output", state = false });
|
||||
}
|
||||
|
||||
// Inputs (0-31)
|
||||
for (int i = 0; i < 32; i++)
|
||||
{
|
||||
string name = $"DIN_{i:D2}";
|
||||
bool initialState = false;
|
||||
if (i == 0) name = "Front Door Sensor";
|
||||
if (i == 1) name = "Right Door Sensor";
|
||||
if (i == 2) name = "Left Door Sensor";
|
||||
if (i == 3) name = "Back Door Sensor";
|
||||
if (i == 4) { name = "Main Air Pressure"; initialState = true; }
|
||||
if (i == 5) { name = "Vacuum Generator"; initialState = true; }
|
||||
if (i == 6) { name = "Emergency Stop Loop"; initialState = true; }
|
||||
|
||||
ioList.Add(new { id = i, name = name, type = "input", state = initialState });
|
||||
}
|
||||
|
||||
return Newtonsoft.Json.JsonConvert.SerializeObject(ioList);
|
||||
}
|
||||
public string GetRecipeList()
|
||||
{
|
||||
var recipes = new System.Collections.Generic.List<object>();
|
||||
recipes.Add(new { id = "1", name = "Wafer_Proc_300_Au", lastModified = "2023-10-25" });
|
||||
recipes.Add(new { id = "2", name = "Wafer_Insp_200_Adv", lastModified = "2023-10-26" });
|
||||
recipes.Add(new { id = "3", name = "Glass_Gen5_Bonding", lastModified = "2023-10-27" });
|
||||
recipes.Add(new { id = "4", name = "Solar_Cell_Cut_A", lastModified = "2023-11-01" });
|
||||
recipes.Add(new { id = "5", name = "LED_Mount_HighSpeed", lastModified = "2023-11-15" });
|
||||
|
||||
return Newtonsoft.Json.JsonConvert.SerializeObject(recipes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace HMIWeb
|
||||
private bool[] inputs = new bool[32];
|
||||
private bool[] outputs = new bool[32];
|
||||
private string systemState = "IDLE";
|
||||
private string currentRecipeId = "1"; // Default recipe
|
||||
public MainForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -77,7 +78,7 @@ namespace HMIWeb
|
||||
webView.CoreWebView2.AddHostObjectToScript("machine", new MachineBridge(this));
|
||||
|
||||
// 3. Load UI
|
||||
//webView.Source = new Uri("http://hmi.local/index.html");
|
||||
webView.Source = new Uri("http://hmi.local/index.html");
|
||||
|
||||
// Disable default browser keys (F5, F12 etc) if needed for kiosk mode
|
||||
webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
||||
@@ -153,6 +154,18 @@ namespace HMIWeb
|
||||
systemState = (cmd == "START") ? "RUNNING" : (cmd == "STOP") ? "PAUSED" : "IDLE";
|
||||
}
|
||||
|
||||
public void SetCurrentRecipe(string recipeId)
|
||||
{
|
||||
currentRecipeId = recipeId;
|
||||
Console.WriteLine($"[MainForm] Current recipe set to: {recipeId}");
|
||||
// In real app, load recipe parameters here
|
||||
}
|
||||
|
||||
public string GetCurrentRecipe()
|
||||
{
|
||||
return currentRecipeId;
|
||||
}
|
||||
|
||||
private double Lerp(double a, double b, double t) => a + (b - a) * t;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ namespace HMIWeb
|
||||
dynamic json = Newtonsoft.Json.JsonConvert.DeserializeObject(msg);
|
||||
string type = json.type;
|
||||
|
||||
Console.WriteLine( $"HandleMessage:{type}" );
|
||||
if (type == "GET_CONFIG")
|
||||
{
|
||||
// Simulate Delay for Loading Screen Test
|
||||
@@ -131,6 +132,20 @@ namespace HMIWeb
|
||||
var response = new { type = "CONFIG_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(configJson) };
|
||||
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
}
|
||||
else if (type == "GET_IO_LIST")
|
||||
{
|
||||
var bridge = new MachineBridge(_mainForm);
|
||||
string ioJson = bridge.GetIOList();
|
||||
var response = new { type = "IO_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(ioJson) };
|
||||
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
}
|
||||
else if (type == "GET_RECIPE_LIST")
|
||||
{
|
||||
var bridge = new MachineBridge(_mainForm);
|
||||
string recipeJson = bridge.GetRecipeList();
|
||||
var response = new { type = "RECIPE_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(recipeJson) };
|
||||
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
}
|
||||
else if (type == "SAVE_CONFIG")
|
||||
{
|
||||
string configJson = Newtonsoft.Json.JsonConvert.SerializeObject(json.data);
|
||||
@@ -154,6 +169,14 @@ namespace HMIWeb
|
||||
bool state = json.state;
|
||||
_mainForm.Invoke(new Action(() => _mainForm.SetOutput(id, state)));
|
||||
}
|
||||
else if (type == "SELECT_RECIPE")
|
||||
{
|
||||
string recipeId = json.recipeId;
|
||||
var bridge = new MachineBridge(_mainForm);
|
||||
string resultJson = bridge.SelectRecipe(recipeId);
|
||||
var response = new { type = "RECIPE_SELECTED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
|
||||
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
343
frontend/App.tsx
343
frontend/App.tsx
@@ -1,26 +1,13 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Activity, Settings, Move, Camera, Play, Square, RotateCw,
|
||||
Cpu, AlertTriangle, Siren, Terminal, Layers
|
||||
} from 'lucide-react';
|
||||
import { Machine3D } from './components/Machine3D';
|
||||
import { SettingsModal } from './components/SettingsModal';
|
||||
import { RecipePanel } from './components/RecipePanel';
|
||||
import { IOPanel } from './components/IOPanel';
|
||||
import { MotionPanel } from './components/MotionPanel';
|
||||
import { CameraPanel } from './components/CameraPanel';
|
||||
import { CyberPanel } from './components/common/CyberPanel';
|
||||
import { TechButton } from './components/common/TechButton';
|
||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { IOMonitorPage } from './pages/IOMonitorPage';
|
||||
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
|
||||
import { comms } from './communication';
|
||||
|
||||
// --- MOCK DATA ---
|
||||
const MOCK_RECIPES: Recipe[] = [
|
||||
{ id: '1', name: 'Wafer_Proc_300_Au', lastModified: '2023-10-25' },
|
||||
{ id: '2', name: 'Wafer_Insp_200_Adv', lastModified: '2023-10-26' },
|
||||
{ id: '3', name: 'Glass_Gen5_Bonding', lastModified: '2023-10-27' },
|
||||
];
|
||||
|
||||
|
||||
const INITIAL_IO: IOPoint[] = [
|
||||
...Array.from({ length: 32 }, (_, i) => {
|
||||
@@ -47,34 +34,35 @@ const INITIAL_IO: IOPoint[] = [
|
||||
// --- MAIN APP ---
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | null>(null);
|
||||
const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in');
|
||||
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
|
||||
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]);
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [currentRecipe, setCurrentRecipe] = useState<Recipe | null>(null);
|
||||
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [ioPoints, setIoPoints] = useState<IOPoint[]>(INITIAL_IO);
|
||||
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [config, setConfig] = useState<ConfigItem[] | null>(null);
|
||||
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const videoRef = useRef<any>(null);
|
||||
|
||||
// Check if running in WebView2 context
|
||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||
const [isHostConnected, setIsHostConnected] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// -- COMMUNICATION LAYER --
|
||||
useEffect(() => {
|
||||
// Subscribe to unified communication layer
|
||||
const unsubscribe = comms.subscribe((msg: any) => {
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === 'CONNECTION_STATE') {
|
||||
setIsHostConnected(msg.connected);
|
||||
addLog(msg.connected ? "HOST CONNECTED" : "HOST DISCONNECTED", msg.connected ? "info" : "warning");
|
||||
}
|
||||
|
||||
if (msg.type === 'STATUS_UPDATE') {
|
||||
// Update Motion State
|
||||
if (msg.position) {
|
||||
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
|
||||
}
|
||||
// Update IO State (Merge with existing names/configs)
|
||||
if (msg.ioState) {
|
||||
setIoPoints(prev => {
|
||||
const newIO = [...prev];
|
||||
@@ -85,7 +73,6 @@ export default function App() {
|
||||
return newIO;
|
||||
});
|
||||
}
|
||||
// Update System State
|
||||
if (msg.sysState) {
|
||||
setSystemState(msg.sysState as SystemState);
|
||||
}
|
||||
@@ -93,8 +80,8 @@ export default function App() {
|
||||
});
|
||||
|
||||
addLog("COMMUNICATION CHANNEL OPEN", "info");
|
||||
setIsHostConnected(comms.getConnectionState());
|
||||
|
||||
// Timer for Clock
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
@@ -105,17 +92,29 @@ export default function App() {
|
||||
// -- INITIALIZATION --
|
||||
useEffect(() => {
|
||||
const initSystem = async () => {
|
||||
// Just start up without fetching config
|
||||
addLog("SYSTEM STARTED", "info");
|
||||
try {
|
||||
const ioStr = await comms.getIOList();
|
||||
const ioData = JSON.parse(ioStr);
|
||||
setIoPoints(ioData);
|
||||
addLog("IO LIST LOADED", "info");
|
||||
|
||||
const recipeStr = await comms.getRecipeList();
|
||||
const recipeData = JSON.parse(recipeStr);
|
||||
setRecipes(recipeData);
|
||||
if (recipeData.length > 0) setCurrentRecipe(recipeData[0]);
|
||||
addLog("RECIPE LIST LOADED", "info");
|
||||
} catch (e) {
|
||||
addLog("FAILED TO LOAD SYSTEM DATA", "error");
|
||||
// Fallback to empty or keep initial if needed
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
initSystem();
|
||||
}, []);
|
||||
|
||||
// -- ON-DEMAND CONFIG FETCH --
|
||||
useEffect(() => {
|
||||
if (activeTab === 'setting') {
|
||||
const fetchConfig = async () => {
|
||||
// -- CONFIG FETCHING (for settings modal) --
|
||||
const fetchConfig = React.useCallback(async () => {
|
||||
setIsConfigRefreshing(true);
|
||||
try {
|
||||
const configStr = await comms.getConfig();
|
||||
@@ -125,10 +124,7 @@ export default function App() {
|
||||
addLog("CONFIG REFRESH FAILED", "error");
|
||||
}
|
||||
setIsConfigRefreshing(false);
|
||||
};
|
||||
fetchConfig();
|
||||
}
|
||||
}, [activeTab]);
|
||||
}, []);
|
||||
|
||||
const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => {
|
||||
setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50));
|
||||
@@ -146,8 +142,6 @@ export default function App() {
|
||||
|
||||
// -- COMMAND HANDLERS --
|
||||
|
||||
// -- COMMAND HANDLERS --
|
||||
|
||||
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
|
||||
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
|
||||
|
||||
@@ -160,7 +154,6 @@ export default function App() {
|
||||
};
|
||||
|
||||
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
|
||||
// Only allow output toggling
|
||||
if (type === 'output') {
|
||||
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
|
||||
const nextState = forceState !== undefined ? forceState : !current;
|
||||
@@ -174,215 +167,87 @@ export default function App() {
|
||||
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
|
||||
try {
|
||||
await comms.saveConfig(JSON.stringify(newConfig));
|
||||
setConfig(newConfig);
|
||||
addLog("CONFIGURATION SAVED", "success");
|
||||
addLog("CONFIGURATION SAVED", "info");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addLog("FAILED TO SAVE CONFIG", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRecipe = async (r: Recipe) => {
|
||||
try {
|
||||
addLog(`LOADING: ${r.name}`, 'info');
|
||||
const result = await comms.selectRecipe(r.id);
|
||||
|
||||
if (result.success) {
|
||||
setCurrentRecipe(r);
|
||||
addLog(`RECIPE LOADED: ${r.name}`, 'info');
|
||||
} else {
|
||||
addLog(`RECIPE LOAD FAILED: ${result.message}`, 'error');
|
||||
}
|
||||
} catch (error: any) {
|
||||
addLog(`RECIPE LOAD ERROR: ${error.message || 'Unknown error'}`, 'error');
|
||||
console.error('Recipe selection error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
|
||||
|
||||
{/* Animated Nebula Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
|
||||
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
|
||||
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
|
||||
|
||||
{/* LOADING OVERLAY */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
|
||||
<Cpu className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue w-10 h-10 animate-pulse" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
|
||||
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HEADER */}
|
||||
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||
<div className="flex items-center gap-4 pointer-events-auto">
|
||||
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur">
|
||||
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue">
|
||||
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
|
||||
</h1>
|
||||
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
|
||||
<span>SYS.VER 4.2.0</span>
|
||||
<span>|</span>
|
||||
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
|
||||
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10">
|
||||
{[
|
||||
{ id: 'recipe', icon: Layers, label: 'RECIPE' },
|
||||
{ id: 'io', icon: Activity, label: 'I/O MONITOR' },
|
||||
{ id: 'motion', icon: Move, label: 'MOTION' },
|
||||
{ id: 'camera', icon: Camera, label: 'VISION' },
|
||||
{ id: 'setting', icon: Settings, label: 'CONFIG' }
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(activeTab === item.id ? null : item.id as any)}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
|
||||
${activeTab === item.id
|
||||
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'}
|
||||
`}
|
||||
<HashRouter>
|
||||
<Layout
|
||||
currentTime={currentTime}
|
||||
isHostConnected={isHostConnected}
|
||||
robotTarget={robotTarget}
|
||||
onTabChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === null) setActiveIOTab('in'); // Reset IO tab when closing
|
||||
}}
|
||||
activeTab={activeTab}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<item.icon className="w-4 h-4" /> {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-right pointer-events-auto">
|
||||
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
|
||||
{currentTime.toLocaleTimeString('en-GB')}
|
||||
</div>
|
||||
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
|
||||
{currentTime.toLocaleDateString().toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<main className="absolute inset-0 pt-24 pb-12 px-6 flex gap-6 z-10">
|
||||
|
||||
{/* 3D Canvas (Background Layer) */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
|
||||
</div>
|
||||
|
||||
{/* Center Alarms */}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
|
||||
{isEmergencyStop && (
|
||||
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
|
||||
<Siren className="w-16 h-16 animate-spin" />
|
||||
<div>
|
||||
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
|
||||
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLowPressure && !isEmergencyStop && (
|
||||
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
|
||||
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Panel (Left) */}
|
||||
{activeTab && activeTab !== 'setting' && (
|
||||
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
|
||||
<CyberPanel className="h-full flex flex-col">
|
||||
{activeTab === 'recipe' && <RecipePanel recipes={MOCK_RECIPES} currentRecipe={currentRecipe} onSelectRecipe={(r) => { setCurrentRecipe(r); addLog(`LOADED: ${r.name}`) }} />}
|
||||
{activeTab === 'io' && <IOPanel ioPoints={ioPoints} onToggle={toggleIO} />}
|
||||
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={moveAxis} />}
|
||||
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
|
||||
</CyberPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={activeTab === 'setting'}
|
||||
onClose={() => setActiveTab(null)}
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<HomePage
|
||||
systemState={systemState}
|
||||
currentRecipe={currentRecipe || { id: '0', name: 'No Recipe', lastModified: '-' }}
|
||||
recipes={recipes}
|
||||
robotTarget={robotTarget}
|
||||
logs={logs}
|
||||
ioPoints={ioPoints}
|
||||
config={config}
|
||||
isRefreshing={isConfigRefreshing}
|
||||
onSave={handleSaveConfig}
|
||||
isConfigRefreshing={isConfigRefreshing}
|
||||
doorStates={doorStates}
|
||||
isLowPressure={isLowPressure}
|
||||
isEmergencyStop={isEmergencyStop}
|
||||
activeTab={activeTab}
|
||||
onSelectRecipe={handleSelectRecipe}
|
||||
onMove={moveAxis}
|
||||
onControl={handleControl}
|
||||
onSaveConfig={handleSaveConfig}
|
||||
onFetchConfig={fetchConfig}
|
||||
onCloseTab={() => setActiveTab(null)}
|
||||
videoRef={videoRef}
|
||||
/>
|
||||
|
||||
{/* Right Sidebar (Dashboard) */}
|
||||
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
|
||||
<CyberPanel className="flex-none">
|
||||
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
|
||||
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
|
||||
{systemState}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<TechButton
|
||||
variant="green"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
active={systemState === SystemState.RUNNING}
|
||||
onClick={() => handleControl('start')}
|
||||
>
|
||||
<Play className="w-4 h-4" /> START AUTO
|
||||
</TechButton>
|
||||
<TechButton
|
||||
variant="amber"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
active={systemState === SystemState.PAUSED}
|
||||
onClick={() => handleControl('stop')}
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
|
||||
</TechButton>
|
||||
<TechButton
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={() => handleControl('reset')}
|
||||
>
|
||||
<RotateCw className="w-4 h-4" /> SYSTEM RESET
|
||||
</TechButton>
|
||||
</div>
|
||||
</CyberPanel>
|
||||
|
||||
<CyberPanel className="flex-1 flex flex-col min-h-0">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
|
||||
<span>Event Log</span>
|
||||
<Terminal className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
|
||||
<span className="opacity-50">[{log.timestamp}]</span>
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CyberPanel>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
{/* FOOTER STATUS */}
|
||||
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
|
||||
<div className="flex gap-6">
|
||||
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
|
||||
<div key={hw} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
|
||||
<span className="font-bold text-slate-300">{hw}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-8 text-neon-blue">
|
||||
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
|
||||
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
|
||||
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/io-monitor"
|
||||
element={
|
||||
<IOMonitorPage
|
||||
ioPoints={ioPoints}
|
||||
onToggle={toggleIO}
|
||||
activeIOTab={activeIOTab}
|
||||
onIOTabChange={setActiveIOTab}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class CommunicationLayer {
|
||||
constructor() {
|
||||
if (isWebView) {
|
||||
console.log("[COMM] Running in WebView2 Mode");
|
||||
this.isConnected = true; // WebView2 is always connected
|
||||
window.chrome.webview.addEventListener('message', (event: any) => {
|
||||
this.notifyListeners(event.data);
|
||||
});
|
||||
@@ -27,6 +28,7 @@ class CommunicationLayer {
|
||||
this.ws.onopen = () => {
|
||||
console.log("[COMM] WebSocket Connected");
|
||||
this.isConnected = true;
|
||||
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
@@ -41,6 +43,7 @@ class CommunicationLayer {
|
||||
this.ws.onclose = () => {
|
||||
console.log("[COMM] WebSocket Closed. Reconnecting...");
|
||||
this.isConnected = false;
|
||||
this.notifyListeners({ type: 'CONNECTION_STATE', connected: false });
|
||||
setTimeout(() => this.connectWebSocket(), 2000);
|
||||
};
|
||||
|
||||
@@ -60,6 +63,10 @@ class CommunicationLayer {
|
||||
};
|
||||
}
|
||||
|
||||
public getConnectionState(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// --- API Methods ---
|
||||
|
||||
public async getConfig(): Promise<string> {
|
||||
@@ -95,6 +102,64 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
public async getIOList(): Promise<string> {
|
||||
if (isWebView) {
|
||||
return await window.chrome.webview.hostObjects.machine.GetIOList();
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) reject("WebSocket connection timeout");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
reject("IO fetch timeout");
|
||||
}, 10000);
|
||||
|
||||
const handler = (data: any) => {
|
||||
if (data.type === 'IO_LIST_DATA') {
|
||||
clearTimeout(timeoutId);
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
resolve(JSON.stringify(data.data));
|
||||
}
|
||||
};
|
||||
this.listeners.push(handler);
|
||||
this.ws?.send(JSON.stringify({ type: 'GET_IO_LIST' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async getRecipeList(): Promise<string> {
|
||||
if (isWebView) {
|
||||
return await window.chrome.webview.hostObjects.machine.GetRecipeList();
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) reject("WebSocket connection timeout");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
reject("Recipe fetch timeout");
|
||||
}, 10000);
|
||||
|
||||
const handler = (data: any) => {
|
||||
if (data.type === 'RECIPE_LIST_DATA') {
|
||||
clearTimeout(timeoutId);
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
resolve(JSON.stringify(data.data));
|
||||
}
|
||||
};
|
||||
this.listeners.push(handler);
|
||||
this.ws?.send(JSON.stringify({ type: 'GET_RECIPE_LIST' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async saveConfig(configJson: string): Promise<void> {
|
||||
if (isWebView) {
|
||||
await window.chrome.webview.hostObjects.machine.SaveConfig(configJson);
|
||||
@@ -126,6 +191,36 @@ class CommunicationLayer {
|
||||
this.ws?.send(JSON.stringify({ type: 'SET_IO', id, state }));
|
||||
}
|
||||
}
|
||||
|
||||
public async selectRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
|
||||
if (isWebView) {
|
||||
const resultJson = await window.chrome.webview.hostObjects.machine.SelectRecipe(recipeId);
|
||||
return JSON.parse(resultJson);
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
reject({ success: false, message: "Recipe selection timeout" });
|
||||
}, 10000);
|
||||
|
||||
const handler = (data: any) => {
|
||||
if (data.type === 'RECIPE_SELECTED') {
|
||||
clearTimeout(timeoutId);
|
||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||
resolve(data.data);
|
||||
}
|
||||
};
|
||||
this.listeners.push(handler);
|
||||
this.ws?.send(JSON.stringify({ type: 'SELECT_RECIPE', recipeId }));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comms = new CommunicationLayer();
|
||||
|
||||
58
frontend/components/ModelInfoPanel.tsx
Normal file
58
frontend/components/ModelInfoPanel.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Cpu, Activity } from 'lucide-react';
|
||||
import { Recipe } from '../types';
|
||||
import { CyberPanel } from './common/CyberPanel';
|
||||
|
||||
interface ModelInfoPanelProps {
|
||||
currentRecipe: Recipe;
|
||||
}
|
||||
|
||||
export const ModelInfoPanel: React.FC<ModelInfoPanelProps> = ({ currentRecipe }) => {
|
||||
return (
|
||||
<CyberPanel className="flex-none">
|
||||
<div className="mb-3 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
|
||||
<span>Model Information</span>
|
||||
<Box className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 font-mono mb-1">SELECTED MODEL</div>
|
||||
<div className="text-xl font-bold text-white tracking-wide truncate flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-neon-blue" />
|
||||
{currentRecipe.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-white/5 rounded p-2 border border-white/5">
|
||||
<div className="text-[9px] text-slate-500 font-mono uppercase">Model ID</div>
|
||||
<div className="text-sm font-mono text-neon-blue">{currentRecipe.id}</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded p-2 border border-white/5">
|
||||
<div className="text-[9px] text-slate-500 font-mono uppercase">Last Mod</div>
|
||||
<div className="text-sm font-mono text-slate-300">{currentRecipe.lastModified}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] font-mono text-slate-400">
|
||||
<span>TARGET CYCLE</span>
|
||||
<span className="text-neon-green">12.5s</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div className="h-full w-[85%] bg-neon-green/50"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-[10px] font-mono text-slate-400 mt-2">
|
||||
<span>EST. YIELD</span>
|
||||
<span className="text-neon-blue">99.8%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div className="h-full w-[99%] bg-neon-blue/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CyberPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +1,84 @@
|
||||
import React from 'react';
|
||||
import { FileText, CheckCircle2, Layers } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, Check, Settings, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Recipe } from '../types';
|
||||
import { PanelHeader } from './common/PanelHeader';
|
||||
import { TechButton } from './common/TechButton';
|
||||
|
||||
interface RecipePanelProps {
|
||||
recipes: Recipe[];
|
||||
currentRecipe: Recipe;
|
||||
onSelectRecipe: (r: Recipe) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RecipePanel: React.FC<RecipePanelProps> = ({ recipes, currentRecipe, onSelectRecipe }) => (
|
||||
<div className="h-full flex flex-col">
|
||||
<PanelHeader title="Recipe Select" icon={FileText} />
|
||||
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar pr-2">
|
||||
{recipes.map(r => (
|
||||
export const RecipePanel: React.FC<RecipePanelProps> = ({ recipes, currentRecipe, onSelectRecipe, onClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedId, setSelectedId] = useState<string>(currentRecipe.id);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selected = recipes.find(r => r.id === selectedId);
|
||||
if (selected) {
|
||||
onSelectRecipe(selected);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-[500px] bg-slate-950/90 border border-neon-blue/30 rounded-lg shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="h-12 bg-white/5 flex items-center justify-between px-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-2 text-neon-blue font-tech font-bold tracking-wider">
|
||||
<Layers className="w-4 h-4" /> RECIPE SELECTION
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List (Max height for ~5 items) */}
|
||||
<div className="p-4 max-h-[320px] overflow-y-auto custom-scrollbar">
|
||||
<div className="space-y-2">
|
||||
{recipes.map(recipe => (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => onSelectRecipe(r)}
|
||||
key={recipe.id}
|
||||
onClick={() => setSelectedId(recipe.id)}
|
||||
className={`
|
||||
p-3 cursor-pointer flex justify-between items-center transition-all border-l-4
|
||||
${currentRecipe.id === r.id
|
||||
? 'bg-white/10 border-neon-blue text-white shadow-[inset_0_0_20px_rgba(0,243,255,0.1)]'
|
||||
: 'bg-black/20 border-transparent text-slate-500 hover:bg-white/5 hover:text-slate-300'}
|
||||
p-3 rounded border cursor-pointer transition-all flex items-center justify-between
|
||||
${selectedId === recipe.id
|
||||
? 'bg-neon-blue/20 border-neon-blue text-white shadow-[0_0_10px_rgba(0,243,255,0.2)]'
|
||||
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20'}
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-tech font-bold text-sm tracking-wide">{r.name}</div>
|
||||
<div className="text-[10px] font-mono opacity-60">{r.lastModified}</div>
|
||||
<div className="font-bold tracking-wide">{recipe.name}</div>
|
||||
<div className="text-[10px] font-mono opacity-70">ID: {recipe.id} | MOD: {recipe.lastModified}</div>
|
||||
</div>
|
||||
{currentRecipe.id === r.id && <CheckCircle2 className="w-4 h-4 text-neon-blue" />}
|
||||
{selectedId === recipe.id && <Check className="w-4 h-4 text-neon-blue" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TechButton className="w-full mt-4 flex items-center justify-center gap-2">
|
||||
<Layers className="w-4 h-4" /> New Recipe
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t border-white/10 flex justify-between gap-4 bg-black/20">
|
||||
<TechButton
|
||||
variant="default"
|
||||
className="flex items-center gap-2 px-4"
|
||||
onClick={() => navigate('/recipe')}
|
||||
>
|
||||
<Settings className="w-4 h-4" /> MANAGEMENT
|
||||
</TechButton>
|
||||
|
||||
<TechButton
|
||||
variant="blue"
|
||||
className="flex items-center gap-2 px-8"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
SELECT
|
||||
</TechButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@ interface TechButtonProps {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
variant?: 'blue' | 'red' | 'amber' | 'green';
|
||||
variant?: 'blue' | 'red' | 'amber' | 'green' | 'default' | 'danger';
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const TechButton: React.FC<TechButtonProps> = ({
|
||||
@@ -13,26 +15,39 @@ export const TechButton: React.FC<TechButtonProps> = ({
|
||||
onClick,
|
||||
active = false,
|
||||
variant = 'blue',
|
||||
className = ''
|
||||
className = '',
|
||||
disabled = false,
|
||||
title
|
||||
}) => {
|
||||
const colors = {
|
||||
blue: 'from-blue-600 to-cyan-600 hover:shadow-glow-blue border-cyan-400/30',
|
||||
red: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30',
|
||||
amber: 'from-amber-500 to-orange-600 hover:shadow-orange-500/50 border-orange-400/30',
|
||||
green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30'
|
||||
green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30',
|
||||
default: 'from-slate-600 to-slate-500 hover:shadow-slate-500/50 border-slate-400/30',
|
||||
danger: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30'
|
||||
};
|
||||
|
||||
const variantKey = variant === 'danger' ? 'red' : (variant === 'default' ? 'blue' : variant);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`
|
||||
relative px-4 py-2 font-tech font-bold tracking-wider uppercase transition-all duration-300
|
||||
clip-tech border-b-2 border-r-2
|
||||
${active ? `bg-gradient-to-r ${colors[variant]} text-white` : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 border-slate-600'}
|
||||
${disabled
|
||||
? 'opacity-50 cursor-not-allowed bg-slate-900 text-slate-600 border-slate-800'
|
||||
: (active
|
||||
? `bg-gradient-to-r ${colors[variantKey]} text-white`
|
||||
: 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 border-slate-600')
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{active && <div className="absolute inset-0 bg-white/20 animate-pulse pointer-events-none"></div>}
|
||||
{active && !disabled && <div className="absolute inset-0 bg-white/20 animate-pulse pointer-events-none"></div>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
31
frontend/components/layout/Footer.tsx
Normal file
31
frontend/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { RobotTarget } from '../../types';
|
||||
|
||||
interface FooterProps {
|
||||
isHostConnected: boolean;
|
||||
robotTarget: RobotTarget;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({ isHostConnected, robotTarget }) => {
|
||||
return (
|
||||
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
|
||||
<div className="flex gap-6">
|
||||
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
|
||||
<div key={hw} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
|
||||
<span className="font-bold text-slate-300">{hw}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full transition-all ${isHostConnected ? 'bg-neon-green shadow-[0_0_5px_#0aff00]' : 'bg-red-500 shadow-[0_0_5px_#ff0000] animate-pulse'}`}></div>
|
||||
<span className={`font-bold ${isHostConnected ? 'text-slate-300' : 'text-red-400'}`}>HOST</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 text-neon-blue">
|
||||
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
|
||||
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
|
||||
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
100
frontend/components/layout/Header.tsx
Normal file
100
frontend/components/layout/Header.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Activity, Settings, Move, Camera, Layers, Cpu } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
currentTime: Date;
|
||||
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void;
|
||||
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
|
||||
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, activeTab }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||
const isIOPage = location.pathname === '/io-monitor';
|
||||
|
||||
return (
|
||||
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-4 pointer-events-auto cursor-pointer group"
|
||||
onClick={() => {
|
||||
navigate('/');
|
||||
onTabChange(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur group-hover:bg-neon-blue/10 transition-colors">
|
||||
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue group-hover:text-neon-blue transition-colors">
|
||||
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
|
||||
</h1>
|
||||
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
|
||||
<span>SYS.VER 4.2.0</span>
|
||||
<span>|</span>
|
||||
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
|
||||
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* IO Tab Switcher (only on IO page) */}
|
||||
|
||||
|
||||
<div className="bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10 flex gap-1">
|
||||
{[
|
||||
{ id: 'recipe', icon: Layers, label: 'RECIPE', path: '/' },
|
||||
{ id: 'io', icon: Activity, label: 'I/O MONITOR', path: '/io-monitor' },
|
||||
{ id: 'motion', icon: Move, label: 'MOTION', path: '/' },
|
||||
{ id: 'camera', icon: Camera, label: 'VISION', path: '/' },
|
||||
{ id: 'setting', icon: Settings, label: 'CONFIG', path: '/' }
|
||||
].map(item => {
|
||||
const isActive = item.id === 'io'
|
||||
? location.pathname === '/io-monitor'
|
||||
: activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (item.id === 'io') {
|
||||
navigate('/io-monitor');
|
||||
onTabChange(null);
|
||||
} else {
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/');
|
||||
}
|
||||
onTabChange(activeTab === item.id ? null : item.id as any);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
|
||||
${isActive
|
||||
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
<item.icon className="w-4 h-4" /> {item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right pointer-events-auto">
|
||||
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
|
||||
{currentTime.toLocaleTimeString('en-GB')}
|
||||
</div>
|
||||
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
|
||||
{currentTime.toLocaleDateString().toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
61
frontend/components/layout/Layout.tsx
Normal file
61
frontend/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Header } from './Header';
|
||||
import { Footer } from './Footer';
|
||||
import { RobotTarget } from '../../types';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentTime: Date;
|
||||
isHostConnected: boolean;
|
||||
robotTarget: RobotTarget;
|
||||
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void;
|
||||
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({
|
||||
children,
|
||||
currentTime,
|
||||
isHostConnected,
|
||||
robotTarget,
|
||||
onTabChange,
|
||||
activeTab,
|
||||
isLoading
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
|
||||
{/* Animated Nebula Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
|
||||
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
|
||||
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
|
||||
|
||||
{/* LOADING OVERLAY */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue text-2xl font-tech font-bold">●</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
|
||||
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Header
|
||||
currentTime={currentTime}
|
||||
onTabChange={onTabChange}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="absolute inset-0 pt-20 pb-10 z-10">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Footer isHostConnected={isHostConnected} robotTarget={robotTarget} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
frontend/package-lock.json
generated
54
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"three": "^0.160.0"
|
||||
},
|
||||
@@ -1800,6 +1801,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
@@ -2801,6 +2811,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
|
||||
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
|
||||
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
@@ -2965,6 +3013,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"three": "^0.160.0"
|
||||
},
|
||||
|
||||
174
frontend/pages/HomePage.tsx
Normal file
174
frontend/pages/HomePage.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Square, RotateCw, AlertTriangle, Siren, Terminal } from 'lucide-react';
|
||||
import { Machine3D } from '../components/Machine3D';
|
||||
import { SettingsModal } from '../components/SettingsModal';
|
||||
import { RecipePanel } from '../components/RecipePanel';
|
||||
import { MotionPanel } from '../components/MotionPanel';
|
||||
import { CameraPanel } from '../components/CameraPanel';
|
||||
import { CyberPanel } from '../components/common/CyberPanel';
|
||||
import { TechButton } from '../components/common/TechButton';
|
||||
import { ModelInfoPanel } from '../components/ModelInfoPanel';
|
||||
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from '../types';
|
||||
|
||||
interface HomePageProps {
|
||||
systemState: SystemState;
|
||||
currentRecipe: Recipe;
|
||||
recipes: Recipe[];
|
||||
robotTarget: RobotTarget;
|
||||
logs: LogEntry[];
|
||||
ioPoints: IOPoint[];
|
||||
config: ConfigItem[] | null;
|
||||
isConfigRefreshing: boolean;
|
||||
doorStates: { front: boolean; right: boolean; left: boolean; back: boolean };
|
||||
isLowPressure: boolean;
|
||||
isEmergencyStop: boolean;
|
||||
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
|
||||
onSelectRecipe: (r: Recipe) => void;
|
||||
onMove: (axis: 'X' | 'Y' | 'Z', val: number) => void;
|
||||
onControl: (action: 'start' | 'stop' | 'reset') => void;
|
||||
onSaveConfig: (config: ConfigItem[]) => void;
|
||||
onFetchConfig: () => void;
|
||||
onCloseTab: () => void;
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
export const HomePage: React.FC<HomePageProps> = ({
|
||||
systemState,
|
||||
currentRecipe,
|
||||
recipes,
|
||||
robotTarget,
|
||||
logs,
|
||||
ioPoints,
|
||||
config,
|
||||
isConfigRefreshing,
|
||||
doorStates,
|
||||
isLowPressure,
|
||||
isEmergencyStop,
|
||||
activeTab,
|
||||
onSelectRecipe,
|
||||
onMove,
|
||||
onControl,
|
||||
onSaveConfig,
|
||||
onFetchConfig,
|
||||
onCloseTab,
|
||||
videoRef
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
|
||||
}
|
||||
}, [activeTab, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'setting') {
|
||||
onFetchConfig();
|
||||
}
|
||||
}, [activeTab, onFetchConfig]);
|
||||
|
||||
return (
|
||||
<main className="relative w-full h-full flex gap-6 px-6">
|
||||
{/* 3D Canvas (Background Layer) */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
|
||||
</div>
|
||||
|
||||
{/* Center Alarms */}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
|
||||
{isEmergencyStop && (
|
||||
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
|
||||
<Siren className="w-16 h-16 animate-spin" />
|
||||
<div>
|
||||
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
|
||||
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLowPressure && !isEmergencyStop && (
|
||||
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
|
||||
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Panel (Left) */}
|
||||
{activeTab && activeTab !== 'setting' && activeTab !== 'recipe' && (
|
||||
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
|
||||
<CyberPanel className="h-full flex flex-col">
|
||||
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={onMove} />}
|
||||
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
|
||||
</CyberPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipe Selection Modal */}
|
||||
{activeTab === 'recipe' && (
|
||||
<RecipePanel
|
||||
recipes={recipes}
|
||||
currentRecipe={currentRecipe}
|
||||
onSelectRecipe={onSelectRecipe}
|
||||
onClose={onCloseTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={activeTab === 'setting'}
|
||||
onClose={onCloseTab}
|
||||
config={config}
|
||||
isRefreshing={isConfigRefreshing}
|
||||
onSave={onSaveConfig}
|
||||
/>
|
||||
|
||||
{/* Right Sidebar (Dashboard) */}
|
||||
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
|
||||
<ModelInfoPanel currentRecipe={currentRecipe} />
|
||||
|
||||
<CyberPanel className="flex-none">
|
||||
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
|
||||
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
|
||||
{systemState}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<TechButton
|
||||
variant="green"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
active={systemState === SystemState.RUNNING}
|
||||
onClick={() => onControl('start')}
|
||||
>
|
||||
<Play className="w-4 h-4" /> START AUTO
|
||||
</TechButton>
|
||||
<TechButton
|
||||
variant="amber"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
active={systemState === SystemState.PAUSED}
|
||||
onClick={() => onControl('stop')}
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
|
||||
</TechButton>
|
||||
<TechButton
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={() => onControl('reset')}
|
||||
>
|
||||
<RotateCw className="w-4 h-4" /> SYSTEM RESET
|
||||
</TechButton>
|
||||
</div>
|
||||
</CyberPanel>
|
||||
|
||||
<CyberPanel className="flex-1 flex flex-col min-h-0">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
|
||||
<span>Event Log</span>
|
||||
<Terminal className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
|
||||
<span className="opacity-50">[{log.timestamp}]</span>
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CyberPanel>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
83
frontend/pages/IOMonitorPage.tsx
Normal file
83
frontend/pages/IOMonitorPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IOPoint } from '../types';
|
||||
|
||||
interface IOMonitorPageProps {
|
||||
ioPoints: IOPoint[];
|
||||
onToggle: (id: number, type: 'input' | 'output') => void;
|
||||
activeIOTab: 'in' | 'out';
|
||||
onIOTabChange: (tab: 'in' | 'out') => void;
|
||||
}
|
||||
|
||||
export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ ioPoints, onToggle, activeIOTab, onIOTabChange }) => {
|
||||
const points = ioPoints.filter(p => p.type === (activeIOTab === 'in' ? 'input' : 'output'));
|
||||
|
||||
return (
|
||||
<main className="relative w-full h-full px-6 pt-6 pb-20">
|
||||
<div className="glass-holo p-6 h-full flex flex-col gap-4">
|
||||
|
||||
{/* Local Header / Controls */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-2xl font-tech font-bold text-white tracking-wider">
|
||||
SYSTEM I/O MONITOR
|
||||
</h2>
|
||||
<div className="h-6 w-px bg-white/20"></div>
|
||||
<div className="text-sm font-mono text-neon-blue">
|
||||
TOTAL POINTS: {ioPoints.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10 flex gap-1">
|
||||
<button
|
||||
onClick={() => onIOTabChange('in')}
|
||||
className={`px-6 py-2 rounded-full font-tech font-bold text-sm transition-all ${activeIOTab === 'in' ? 'bg-neon-yellow/20 text-neon-yellow border border-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.3)]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
INPUTS ({ioPoints.filter(p => p.type === 'input').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onIOTabChange('out')}
|
||||
className={`px-6 py-2 rounded-full font-tech font-bold text-sm transition-all ${activeIOTab === 'out' ? 'bg-neon-green/20 text-neon-green border border-neon-green shadow-[0_0_15px_rgba(10,255,0,0.3)]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
OUTPUTS ({ioPoints.filter(p => p.type === 'output').length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/40 backdrop-blur-md flex-1 overflow-y-auto custom-scrollbar rounded-lg border border-white/5">
|
||||
{/* Grid Layout - More columns for full screen */}
|
||||
{/* Grid Layout - 2 Columns for list view */}
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 p-4">
|
||||
{points.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => onToggle(p.id, p.type)}
|
||||
className={`
|
||||
flex items-center gap-4 px-4 py-3 cursor-pointer transition-all border
|
||||
clip-tech-sm group hover:translate-x-1
|
||||
${p.state
|
||||
? (p.type === 'output'
|
||||
? 'bg-neon-green/10 border-neon-green text-neon-green shadow-[0_0_15px_rgba(10,255,0,0.2)]'
|
||||
: 'bg-neon-yellow/10 border-neon-yellow text-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.2)]')
|
||||
: 'bg-slate-900/40 border-slate-800 text-slate-500 hover:border-slate-600 hover:bg-slate-800/40'}
|
||||
`}
|
||||
>
|
||||
{/* LED Indicator */}
|
||||
<div className={`w-3 h-3 rounded-full shrink-0 transition-all ${p.state ? (p.type === 'output' ? 'bg-neon-green shadow-[0_0_8px_#0aff00]' : 'bg-neon-yellow shadow-[0_0_8px_#ffe600]') : 'bg-slate-800 border border-slate-700'}`}></div>
|
||||
|
||||
{/* ID Badge */}
|
||||
<div className={`w-12 font-mono font-bold text-lg ${p.state ? 'text-white' : 'text-slate-600'}`}>
|
||||
{p.type === 'input' ? 'I' : 'Q'}{p.id.toString().padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className={`flex-1 font-bold uppercase tracking-wide truncate ${p.state ? 'text-white' : 'text-slate-500'} group-hover:text-white transition-colors`}>
|
||||
{p.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
173
frontend/pages/RecipePage.tsx
Normal file
173
frontend/pages/RecipePage.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layers, Plus, Trash2, Copy, Save, ArrowLeft, FileText, Calendar } from 'lucide-react';
|
||||
import { Recipe } from '../types';
|
||||
import { comms } from '../communication';
|
||||
import { TechButton } from '../components/common/TechButton';
|
||||
|
||||
export const RecipePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editedRecipe, setEditedRecipe] = useState<Recipe | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecipes = async () => {
|
||||
try {
|
||||
const recipeStr = await comms.getRecipeList();
|
||||
const recipeData = JSON.parse(recipeStr);
|
||||
setRecipes(recipeData);
|
||||
if (recipeData.length > 0) {
|
||||
setSelectedId(recipeData[0].id);
|
||||
setEditedRecipe(recipeData[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load recipes", e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
loadRecipes();
|
||||
}, []);
|
||||
|
||||
const handleSelect = (r: Recipe) => {
|
||||
setSelectedId(r.id);
|
||||
setEditedRecipe({ ...r });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Mock Save Logic
|
||||
console.log("Saving recipe:", editedRecipe);
|
||||
// In real app: await comms.saveRecipe(editedRecipe);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6 flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-2 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-tech font-bold text-white tracking-wider flex items-center gap-3">
|
||||
<Layers className="text-neon-blue" /> RECIPE MANAGEMENT
|
||||
</h1>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-slate-400">
|
||||
TOTAL: <span className="text-neon-blue">{recipes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex gap-6 min-h-0">
|
||||
{/* Left: Recipe List */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
<div className="bg-slate-950/40 backdrop-blur-md border border-white/10 rounded-lg flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b border-white/10 bg-white/5 font-bold text-sm tracking-wider text-slate-300">
|
||||
RECIPE LIST
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
|
||||
{recipes.map(r => (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => handleSelect(r)}
|
||||
className={`
|
||||
p-3 rounded border cursor-pointer transition-all group
|
||||
${selectedId === r.id
|
||||
? 'bg-neon-blue/20 border-neon-blue text-white shadow-glow-blue'
|
||||
: 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:border-white/20'}
|
||||
`}
|
||||
>
|
||||
<div className="font-bold text-sm mb-1">{r.name}</div>
|
||||
<div className="text-[10px] font-mono opacity-60 flex items-center gap-2">
|
||||
<Calendar className="w-3 h-3" /> {r.lastModified}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List Actions */}
|
||||
<div className="p-3 border-t border-white/10 bg-black/20 grid grid-cols-3 gap-2">
|
||||
<TechButton variant="default" className="flex justify-center" title="Add New">
|
||||
<Plus className="w-4 h-4" />
|
||||
</TechButton>
|
||||
<TechButton variant="default" className="flex justify-center" title="Copy Selected">
|
||||
<Copy className="w-4 h-4" />
|
||||
</TechButton>
|
||||
<TechButton variant="danger" className="flex justify-center" title="Delete Selected">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</TechButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Editor */}
|
||||
<div className="flex-1 bg-slate-950/40 backdrop-blur-md border border-white/10 rounded-lg flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-white/10 bg-white/5 font-bold text-sm tracking-wider text-slate-300 flex justify-between items-center">
|
||||
<span>RECIPE EDITOR</span>
|
||||
{editedRecipe && <span className="text-neon-blue font-mono">{editedRecipe.id}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
|
||||
{editedRecipe ? (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Recipe Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedRecipe.name}
|
||||
onChange={(e) => setEditedRecipe({ ...editedRecipe, name: e.target.value })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Description</label>
|
||||
<textarea
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono resize-none"
|
||||
placeholder="Enter recipe description..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Process Time (sec)</label>
|
||||
<input type="number" className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" defaultValue="120" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Temperature (°C)</label>
|
||||
<input type="number" className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" defaultValue="24.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for more parameters */}
|
||||
<div className="p-4 border border-dashed border-white/10 rounded bg-white/5 text-center text-slate-500 text-sm">
|
||||
Additional process parameters would go here...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-500">
|
||||
<FileText className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p>Select a recipe to edit</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor Actions */}
|
||||
<div className="p-4 border-t border-white/10 bg-black/20 flex justify-end">
|
||||
<TechButton
|
||||
variant="green"
|
||||
className="flex items-center gap-2 px-8"
|
||||
onClick={handleSave}
|
||||
disabled={!editedRecipe}
|
||||
>
|
||||
<Save className="w-4 h-4" /> SAVE CHANGES
|
||||
</TechButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -61,6 +61,8 @@ declare global {
|
||||
SystemControl(command: string): Promise<void>;
|
||||
LoadRecipe(recipeId: string): Promise<void>;
|
||||
GetConfig(): Promise<string>;
|
||||
GetIOList(): Promise<string>;
|
||||
GetRecipeList(): Promise<string>;
|
||||
SaveConfig(configJson: string): Promise<void>;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user