feat: 运行时主窗口最小化+悬浮进度窗(alwaysOnTop不可拖动/resize,显示步骤名/进度/耗时/停止按钮,结束后自动恢复主窗口)
This commit is contained in:
parent
6b54decfb0
commit
f163c8d0b6
@ -12,7 +12,8 @@ import "./capabilities";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
import { capabilityCount, capabilityIds } from "./engine/registry";
|
||||
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
|
||||
import { closeBrowserSession, setMainWindowGetter, startRun, stopRun } from "./run/manager";
|
||||
import { registerOverlayIpc } from "./overlay";
|
||||
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
|
||||
import { loadSettings, updateSettings } from "./settings";
|
||||
import { closeLocalVisionWorkers, defaultUiModelPath } from "./local-vision/ui-detect-service";
|
||||
@ -101,6 +102,8 @@ function registerIpc(): void {
|
||||
|
||||
ipcMain.handle("catalog:get", () => loadCatalog());
|
||||
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
|
||||
setMainWindowGetter(() => mainWindow);
|
||||
registerOverlayIpc(() => mainWindow, () => { stopRun(); });
|
||||
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
|
||||
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
|
||||
ipcMain.handle("settings:get", () => loadSettings());
|
||||
|
||||
144
src/main/overlay.ts
Normal file
144
src/main/overlay.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
let overlayWindow: BrowserWindow | null = null;
|
||||
let cachedProgress = { current: "", done: 0, total: 0 };
|
||||
|
||||
export function showRunOverlay(mainWindow: BrowserWindow | null): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.minimize();
|
||||
|
||||
if (overlayWindow && !overlayWindow.isDestroyed()) {
|
||||
overlayWindow.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const w = 320;
|
||||
const h = 56;
|
||||
const {
|
||||
screen
|
||||
} = require("electron") as typeof import("electron");
|
||||
const workArea = screen.getPrimaryDisplay().workArea;
|
||||
const x = workArea.x + workArea.width - w - 16;
|
||||
const y = workArea.y + workArea.height - h - 16;
|
||||
|
||||
overlayWindow = new BrowserWindow({
|
||||
width: w,
|
||||
height: h,
|
||||
x,
|
||||
y,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
closable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
fullscreenable: false,
|
||||
enableLargerThanScreen: false,
|
||||
hasShadow: true,
|
||||
transparent: false,
|
||||
backgroundColor: "#1E2530",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
overlayWindow.setAlwaysOnTop(true, "screen-saver");
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none}
|
||||
body{width:320px;height:56px;background:#1E2530;color:#E8ECF2;font-family:-apple-system,'Segoe UI',sans-serif;display:flex;align-items:center;padding:0 12px;gap:10px;overflow:hidden;border-radius:8px;border:1px solid #3A4556}
|
||||
.spinner{width:14px;height:14px;border:2px solid #4A90D9;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.info{flex:1;min-width:0}
|
||||
.step{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#B8C4D4}
|
||||
.progress-bar{height:3px;background:#3A4556;border-radius:2px;margin-top:4px;overflow:hidden}
|
||||
.progress-fill{height:100%;background:#4A90D9;border-radius:2px;transition:width .3s ease}
|
||||
.meta{font-size:10px;color:#7A8898;flex-shrink:0;white-space:nowrap}
|
||||
.stop-btn{background:#3A2028;color:#E05252;border:1px solid #5A3038;border-radius:4px;padding:4px 8px;font-size:10px;cursor:pointer;flex-shrink:0}
|
||||
.stop-btn:hover{background:#4A2830}
|
||||
</style></head><body>
|
||||
<div class="spinner" id="spin"></div>
|
||||
<div class="info">
|
||||
<div class="step" id="step">准备运行…</div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="fill" style="width:0%"></div></div>
|
||||
</div>
|
||||
<div class="meta" id="meta">0/0</div>
|
||||
<button class="stop-btn" id="stopBtn" onclick="stopRun()">停止</button>
|
||||
<script>
|
||||
const { ipcRenderer } = require("electron");
|
||||
let done = 0, total = 0, startTime = Date.now();
|
||||
let timer = setInterval(() => {
|
||||
const s = Math.floor((Date.now() - startTime) / 1000);
|
||||
document.getElementById("meta").textContent = done + "/" + total + " · " + s + "s";
|
||||
}, 1000);
|
||||
|
||||
ipcRenderer.on("run:event", (_e, ev) => {
|
||||
if (ev.type === "node.started" && ev.nodeLabel) {
|
||||
document.getElementById("step").textContent = ev.nodeLabel;
|
||||
}
|
||||
if (ev.type === "node.started") { /* spinner already running */ }
|
||||
if (ev.type === "run.started") { startTime = Date.now(); }
|
||||
if (ev.type === "node.finished" || ev.type === "node.skipped") {
|
||||
done++;
|
||||
const pct = total > 0 ? Math.round(done / total * 100) : 0;
|
||||
document.getElementById("fill").style.width = pct + "%";
|
||||
document.getElementById("meta").textContent = done + "/" + total;
|
||||
}
|
||||
if (ev.type === "run.total") {
|
||||
total = ev.total;
|
||||
}
|
||||
if (ev.type === "run.finished") {
|
||||
clearInterval(timer);
|
||||
document.getElementById("spin").style.display = "none";
|
||||
const fill = document.getElementById("fill");
|
||||
fill.style.background = ev.status === "succeeded" ? "#4CAF50" : "#E05252";
|
||||
fill.style.width = "100%";
|
||||
document.getElementById("step").textContent =
|
||||
ev.status === "succeeded" ? "✓ 运行成功" :
|
||||
ev.status === "failed" ? "✗ 运行失败" : "已取消";
|
||||
document.getElementById("stopBtn").textContent = "关闭";
|
||||
document.getElementById("stopBtn").onclick = () => { require("electron").ipcRenderer.send("overlay:close"); };
|
||||
setTimeout(() => { require("electron").ipcRenderer.send("overlay:close"); }, ev.status === "succeeded" ? 3000 : 10000);
|
||||
}
|
||||
});
|
||||
|
||||
function stopRun() {
|
||||
require("electron").ipcRenderer.send("overlay:stop");
|
||||
}
|
||||
<\/script>
|
||||
</body></html>`;
|
||||
|
||||
overlayWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
overlayWindow.show();
|
||||
}
|
||||
|
||||
export function hideRunOverlay(mainWindow: BrowserWindow | null): void {
|
||||
if (overlayWindow && !overlayWindow.isDestroyed()) {
|
||||
overlayWindow.close();
|
||||
overlayWindow = null;
|
||||
}
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.restore();
|
||||
}
|
||||
|
||||
export function registerOverlayIpc(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
onStop: () => void
|
||||
): void {
|
||||
ipcMain.on("overlay:close", () => {
|
||||
hideRunOverlay(getMainWindow());
|
||||
});
|
||||
ipcMain.on("overlay:stop", () => {
|
||||
onStop();
|
||||
});
|
||||
}
|
||||
|
||||
export function overlayBroadcast(event: unknown): void {
|
||||
if (overlayWindow && !overlayWindow.isDestroyed()) {
|
||||
overlayWindow.webContents.send("run:event", event);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { app, type WebContents } from "electron";
|
||||
import { overlayBroadcast, hideRunOverlay, showRunOverlay } from "../overlay";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { FlowDefinition, RunEvent } from "../../shared/types";
|
||||
@ -8,6 +9,9 @@ import { BrowserSession } from "../browser/session";
|
||||
|
||||
const active = new Map<string, AbortController>();
|
||||
let currentRunId: string | null = null;
|
||||
let _getMainWindow: () => import("electron").BrowserWindow | null = () => null;
|
||||
export function setMainWindowGetter(fn: () => import("electron").BrowserWindow | null) { _getMainWindow = fn; }
|
||||
function getMainWindow() { return _getMainWindow(); }
|
||||
|
||||
function newRunId(): string {
|
||||
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
@ -234,7 +234,8 @@ export interface RunEvent {
|
||||
| "node.started"
|
||||
| "node.log"
|
||||
| "node.finished"
|
||||
| "run.finished";
|
||||
| "run.finished"
|
||||
| "run.total";
|
||||
runId: string;
|
||||
nodeId?: string;
|
||||
level?: "info" | "warn" | "error";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user