diff --git a/config/ui_yaml_sample.yaml b/config/ui_yaml_sample.yaml new file mode 100644 index 0000000..c47e7f1 --- /dev/null +++ b/config/ui_yaml_sample.yaml @@ -0,0 +1,92 @@ +ui: + widgets: + - id: input_device + type: input.text + props: + label: "Device Name" + hint: "e.g. Sensor-A" + placeholder: "Enter device label" + default: "Sensor-A" + - id: input_baud + type: input.number + props: + label: "Baud Rate" + hint: "Serial port speed" + default: 115200 + - id: input_enabled + type: input.switch + props: + label: "Enable Relay" + hint: "Toggle relay forwarding" + default: true + - id: input_notes + type: input.textarea + props: + label: "Notes" + placeholder: "Optional remarks" + - id: input_protocol + type: input.select + props: + label: "Protocol" + options: + - label: "Modbus RTU" + value: "modbus_rtu" + - label: "Modbus TCP" + value: "modbus_tcp" + - label: "Custom" + value: "custom" + default: "modbus_rtu" + - id: action_start + type: action.button + emit: "script.start" + props: + label: "Start" + payload: + mode: "run" + - id: action_stop + type: action.button + emit: "script.stop" + props: + label: "Stop" + payload: + mode: "stop" + - id: log_events + type: log.viewer + bind: "ui.events" + props: + title: "Event Log" + - id: inspector + type: inspector.json + props: + title: "Selected Widget" + layout: + type: split + orientation: horizontal + children: + - type: split + orientation: vertical + children: + - type: leaf + title: "Connection" + widgets: + - input_device + - input_baud + - input_enabled + - input_protocol + - type: leaf + title: "Notes" + widgets: + - input_notes + - type: split + orientation: vertical + children: + - type: leaf + title: "Controls" + widgets: + - action_start + - action_stop + - type: leaf + title: "Logs" + widgets: + - log_events + - inspector diff --git a/core/packet_engine.py b/core/packet_engine.py new file mode 100644 index 0000000..59da002 --- /dev/null +++ b/core/packet_engine.py @@ -0,0 +1,179 @@ +"""Packet analysis engine for streaming capture frames. + +Subscribes to comm.rx/comm.tx and publishes structured frame data to the bus. +""" + +from __future__ import annotations + +import queue +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from core.event_bus import EventBus +from core.protocol_loader import crc16_modbus + + +@dataclass +class _ChannelInfo: + channel: str = "" + port: Optional[str] = None + baud: Optional[int] = None + host: Optional[str] = None + address: Optional[str] = None + + +class PacketAnalysisEngine: + """Streaming packet parser that emits capture.frame events.""" + + def __init__(self, bus: EventBus) -> None: + self._bus = bus + self._queue: "queue.Queue[Tuple[str, bytes, float]]" = queue.Queue() + self._channel = _ChannelInfo() + self._enabled = False + self._target_channel: Optional[str] = None + self._counter = 0 + self._stop = threading.Event() + self._worker = threading.Thread(target=self._run, daemon=True) + self._worker.start() + self._bus.subscribe("comm.rx", self._on_rx) + self._bus.subscribe("comm.tx", self._on_tx) + self._bus.subscribe("comm.connected", self._on_connected) + self._bus.subscribe("comm.disconnected", self._on_disconnected) + self._bus.subscribe("capture.control", self._on_control) + + def _on_rx(self, payload: Any) -> None: + if not self._enabled: + return + data = self._to_bytes(payload) + if data: + if self._target_channel and self._channel.channel and self._channel.channel != self._target_channel: + return + self._queue.put(("RX", data, time.time())) + + def _on_tx(self, payload: Any) -> None: + if not self._enabled: + return + data = self._to_bytes(payload) + if data: + if self._target_channel and self._channel.channel and self._channel.channel != self._target_channel: + return + self._queue.put(("TX", data, time.time())) + + def _on_connected(self, payload: Any) -> None: + if isinstance(payload, dict): + self._channel.port = payload.get("port") + self._channel.baud = payload.get("baud") + self._channel.host = payload.get("host") + self._channel.address = payload.get("address") + if payload.get("type") == "serial" and self._channel.port: + self._channel.channel = str(self._channel.port) + elif payload.get("type") == "tcp-client": + self._channel.channel = f"{self._channel.host}:{self._channel.address}" if self._channel.host else "" + + def _on_disconnected(self, payload: Any) -> None: + self._channel = _ChannelInfo() + + def _on_control(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + action = payload.get("action") + if action == "start": + self._enabled = True + channel = payload.get("channel") + self._target_channel = str(channel) if channel else None + elif action == "stop": + self._enabled = False + self._target_channel = None + + def _run(self) -> None: + while not self._stop.is_set(): + try: + direction, data, ts = self._queue.get(timeout=0.2) + except queue.Empty: + continue + frame = self._build_frame(direction, data, ts) + self._bus.publish("capture.frame", frame) + self._queue.task_done() + + def _build_frame(self, direction: str, data: bytes, ts: float) -> Dict[str, Any]: + self._counter += 1 + hex_bytes = [f"{b:02X}" for b in data] + ascii_str = "".join(chr(b) if 32 <= b <= 126 else "." for b in data) + ascii_lines = self._split_ascii(ascii_str, 8) + protocol_name, protocol_unknown, summary, tree_rows, errors = self._parse_protocol(data) + channel = self._channel.channel or "" + frame_id = f"{direction.lower()}-{int(ts * 1000)}-{self._counter}" + return { + "id": frame_id, + "timestamp": ts, + "direction": direction, + "channel": channel, + "baud": self._channel.baud, + "length": len(data), + "raw_hex": " ".join(hex_bytes), + "ascii": ascii_str, + "protocol": { + "name": protocol_name, + "unknown": protocol_unknown, + "confidence": 0.9 if not protocol_unknown else 0.2, + }, + "summary": summary, + "hex_dump": { + "bytes": hex_bytes, + "ascii_lines": ascii_lines, + "size": len(data), + }, + "tree": tree_rows, + "errors": errors, + } + + def _parse_protocol( + self, data: bytes + ) -> Tuple[str, bool, str, List[Dict[str, str]], List[Dict[str, str]]]: + if len(data) < 2: + return "Unknown", True, "Too short", [], [] + + addr = data[0] + func = data[1] + summary = f"addr=0x{addr:02X} func=0x{func:02X} len={len(data)}" + tree = [ + {"label": "Address", "raw": f"{addr:02X}", "value": str(addr)}, + {"label": "Function", "raw": f"{func:02X}", "value": f"0x{func:02X}"}, + ] + + if len(data) >= 4: + crc_ok = self._check_modbus_crc(data) + tree.append( + { + "label": "CRC16", + "raw": " ".join(f"{b:02X}" for b in data[-2:]), + "value": "valid" if crc_ok else "invalid", + } + ) + if crc_ok: + return "Modbus RTU", False, summary, tree, [] + + errors = [{"code": "UNKNOWN_PROTOCOL", "message": "No known signature"}] + return "Unknown", True, summary, tree, errors + + @staticmethod + def _check_modbus_crc(data: bytes) -> bool: + if len(data) < 3: + return False + body = data[:-2] + expected = int.from_bytes(data[-2:], "little") + return crc16_modbus(body) == expected + + @staticmethod + def _split_ascii(text: str, width: int) -> List[str]: + return [text[i : i + width] for i in range(0, len(text), width)] or [""] + + @staticmethod + def _to_bytes(payload: Any) -> bytes: + if isinstance(payload, (bytes, bytearray)): + return bytes(payload) + if isinstance(payload, str): + return payload.encode(errors="ignore") + return b"" diff --git a/ui/script_runner_qt.py b/desktop/script_runner_qt.py similarity index 100% rename from ui/script_runner_qt.py rename to desktop/script_runner_qt.py diff --git a/ui/web_bridge.py b/desktop/web_bridge.py similarity index 59% rename from ui/web_bridge.py rename to desktop/web_bridge.py index 35f0ebe..c5e89fd 100644 --- a/ui/web_bridge.py +++ b/desktop/web_bridge.py @@ -3,12 +3,14 @@ import importlib import json import pkgutil +import re import threading import time from pathlib import Path import logging import os from typing import Any, Dict, List, Optional +import yaml try: from PySide6.QtCore import QObject, Q_ARG, QMetaObject, QTimer, Qt, Signal, Slot @@ -19,7 +21,7 @@ from protocols.registry import ProtocolRegistry import protocols as protocols_pkg -from ui.script_runner_qt import ScriptRunnerQt +from desktop.script_runner_qt import ScriptRunnerQt class WebBridge(QObject): @@ -31,11 +33,13 @@ class WebBridge(QObject): comm_tx = Signal(str) comm_status = Signal(object) protocol_frame = Signal(object) + capture_frame = Signal(object) comm_batch = Signal(str) script_log = Signal(str) script_state = Signal(str) script_progress = Signal(int) channel_update = Signal(object) + ui_event_log = Signal(object) def __init__(self, bus=None, comm=None, window=None) -> None: super().__init__() @@ -48,6 +52,10 @@ def __init__(self, bus=None, comm=None, window=None) -> None: self._protocols_loaded = False self._settings_root = Path(os.environ.get("LOCALAPPDATA", Path.cwd())) / "ProtoFlow" self._settings_path = self._settings_root / "config" / "ui_settings.json" + self._proxy_pairs_path = self._settings_root / "config" / "proxy_pairs.json" + self._protocols_path = self._settings_root / "config" / "protocols.json" + self._proxy_pairs: List[Dict[str, Any]] = self._load_proxy_pairs() + self._custom_protocols: List[Dict[str, Any]] = self._load_custom_protocols() self._channel_state: Dict[str, Any] = { "type": None, "status": "disconnected", @@ -76,6 +84,23 @@ def __init__(self, bus=None, comm=None, window=None) -> None: self._bus.subscribe("comm.disconnected", self._on_comm_status) self._bus.subscribe("comm.error", self._on_comm_status) self._bus.subscribe("protocol.frame", self._on_protocol_frame) + self._bus.subscribe("capture.frame", self._on_capture_frame) + + def _read_app_version(self) -> str: + env_version = os.environ.get("PROTOFLOW_VERSION") + if env_version: + return env_version.strip() + try: + version_path = Path(__file__).resolve().parents[1] / "VERSION" + if version_path.is_file(): + return version_path.read_text(encoding="utf-8").strip() + except Exception: + return "v0.0.0" + return "v0.0.0" + + @Slot(result=str) + def get_app_version(self) -> str: + return self._read_app_version() @Slot(str, result=str) def ping(self, message: str) -> str: @@ -112,21 +137,229 @@ def list_protocols(self) -> List[Dict[str, Any]]: "desc": desc, "category": category, "status": "available", + "source": "builtin", } ) + existing = {item.get("id") for item in items} + for custom in self._custom_protocols: + if not isinstance(custom, dict): + continue + custom_id = custom.get("id") or custom.get("key") + if not custom_id or custom_id in existing: + continue + merged = { + "id": custom_id, + "key": custom.get("key") or custom_id, + "name": custom.get("name") or "", + "driver": custom.get("driver") or "", + "desc": custom.get("desc") or "", + "category": custom.get("category") or "custom", + "status": custom.get("status") or "custom", + "source": "custom", + } + items.append(merged) return items + @Slot(str, result="QVariant") + def parse_ui_yaml(self, yaml_text: str) -> Dict[str, Any]: + try: + data = yaml.safe_load(yaml_text) if yaml_text else {} + return {"ok": True, "value": data} + except yaml.YAMLError as exc: + error: Dict[str, Any] = {"message": str(exc)} + mark = getattr(exc, "problem_mark", None) + if mark is not None: + error["line"] = getattr(mark, "line", None) + 1 if hasattr(mark, "line") else None + error["column"] = getattr(mark, "column", None) + 1 if hasattr(mark, "column") else None + return {"ok": False, "error": error} + + @Slot("QVariant") + def dispatch_ui_event(self, payload: Dict[str, Any]) -> None: + if not isinstance(payload, dict): + return + event = { + "ts": payload.get("ts") or time.time(), + "emit": payload.get("emit") or "", + "payload": payload.get("payload"), + "source": payload.get("source"), + } + self.ui_event_log.emit(event) + + @Slot("QVariant", result="QVariant") + def create_protocol(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + self._load_protocols() + raw_key = payload.get("key") or payload.get("id") or payload.get("name") or "custom_protocol" + key = self._normalize_protocol_key(str(raw_key)) + key = self._ensure_protocol_key_unique(key) + item = { + "id": key, + "key": key, + "name": payload.get("name") or key, + "desc": payload.get("desc") or "", + "category": payload.get("category") or "custom", + "status": payload.get("status") or "custom", + "driver": payload.get("driver") or "", + } + self._custom_protocols.append(item) + self._save_custom_protocols() + return item + + @Slot("QVariant", result="QVariant") + def update_protocol(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + protocol_id = payload.get("id") or payload.get("key") + if not protocol_id: + return {} + updated: Dict[str, Any] = {} + for item in self._custom_protocols: + if item.get("id") == protocol_id or item.get("key") == protocol_id: + item["name"] = payload.get("name") or item.get("name") or item.get("key") or "" + item["desc"] = payload.get("desc") or "" + item["category"] = payload.get("category") or item.get("category") or "custom" + item["status"] = payload.get("status") or item.get("status") or "custom" + if payload.get("driver"): + item["driver"] = payload.get("driver") + updated = dict(item) + break + if updated: + self._save_custom_protocols() + return updated + + @Slot(str, result=bool) + def delete_protocol(self, protocol_id: str) -> bool: + if not protocol_id: + return False + before = len(self._custom_protocols) + self._custom_protocols = [ + item + for item in self._custom_protocols + if item.get("id") != protocol_id and item.get("key") != protocol_id + ] + if len(self._custom_protocols) == before: + return False + self._save_custom_protocols() + return True + @Slot(result="QVariant") def load_settings(self) -> Dict[str, Any]: return self._load_settings() + @Slot(result="QVariant") + def list_proxy_pairs(self) -> List[Dict[str, Any]]: + return list(self._proxy_pairs) + + @Slot(result="QVariant") + def refresh_proxy_pairs(self) -> List[Dict[str, Any]]: + self._proxy_pairs = self._load_proxy_pairs() + return list(self._proxy_pairs) + + @Slot("QVariant", result="QVariant") + def create_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + pair = { + "id": payload.get("id") or f"proxy-{int(time.time() * 1000)}", + "name": payload.get("name") or "未命名转发对", + "hostPort": payload.get("hostPort") or "", + "devicePort": payload.get("devicePort") or "", + "baud": payload.get("baud") or "115200", + "status": payload.get("status") or "stopped", + "dataBits": payload.get("dataBits") or "8", + "stopBits": payload.get("stopBits") or "1", + "parity": payload.get("parity") or "none", + "flowControl": payload.get("flowControl") or "none", + } + self._proxy_pairs.insert(0, pair) + self._save_proxy_pairs() + return pair + + @Slot("QVariant", result="QVariant") + def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + pair_id = payload.get("id") + if not pair_id: + return {} + for idx, pair in enumerate(self._proxy_pairs): + if pair.get("id") == pair_id: + updated = { + **pair, + **{ + k: v + for k, v in payload.items() + if k + in { + "name", + "hostPort", + "devicePort", + "baud", + "status", + "dataBits", + "stopBits", + "parity", + "flowControl", + } + }, + } + self._proxy_pairs[idx] = updated + self._save_proxy_pairs() + return updated + return {} + + @Slot(str, result=bool) + def delete_proxy_pair(self, pair_id: str) -> bool: + if not pair_id: + return False + before = len(self._proxy_pairs) + self._proxy_pairs = [pair for pair in self._proxy_pairs if pair.get("id") != pair_id] + if len(self._proxy_pairs) != before: + self._save_proxy_pairs() + return True + return False + + @Slot(str, bool, result="QVariant") + def set_proxy_pair_status(self, pair_id: str, active: bool) -> Dict[str, Any]: + status = "running" if active else "stopped" + for idx, pair in enumerate(self._proxy_pairs): + if pair.get("id") == pair_id: + pair = dict(pair) + pair["status"] = status + self._proxy_pairs[idx] = pair + self._save_proxy_pairs() + return pair + return {} + @Slot("QVariant", result=bool) def save_settings(self, payload: Dict[str, Any]) -> bool: return self._save_settings(payload) + @Slot("QVariant", result=bool) + def start_capture(self, payload: Dict[str, Any]) -> bool: + if not isinstance(payload, dict): + return False + channel = payload.get("channel") or payload.get("hostPort") + pair_id = payload.get("id") or payload.get("pairId") + self._bus.publish( + "capture.control", + { + "action": "start", + "channel": channel, + "pair_id": pair_id, + }, + ) + return True + + @Slot(result=bool) + def stop_capture(self) -> bool: + self._bus.publish("capture.control", {"action": "stop"}) + return True + @Slot(str, str, result=str) def select_directory(self, title: str, start_dir: str) -> str: - return QFileDialog.getExistingDirectory(None, title or "选择目录", start_dir or "") + return QFileDialog.getExistingDirectory(None, title or "Select directory", start_dir or "") @Slot(str, int) def connect_serial(self, port: str, baud: int = 115200) -> None: @@ -207,7 +440,7 @@ def stop_script(self) -> None: def load_yaml(self) -> Dict[str, str]: path, _ = QFileDialog.getOpenFileName( None, - "选择 YAML 脚本", + "Select YAML file", str(Path.cwd()), "YAML Files (*.yaml *.yml)", ) @@ -228,7 +461,7 @@ def save_yaml(self, yaml_text: str, suggested_name: str = "workflow.yaml") -> Di default_path = Path.cwd() / (suggested_name or "workflow.yaml") path, _ = QFileDialog.getSaveFileName( None, - "保存 YAML 脚本", + "Save YAML file", str(default_path), "YAML Files (*.yaml *.yml)", ) @@ -367,6 +600,10 @@ def _emit_comm_rx_signal(self, payload: str) -> None: def _emit_comm_tx_signal(self, payload: str) -> None: self.comm_tx.emit(payload) + @Slot("QVariant") + def _emit_capture_frame_signal(self, payload: Any) -> None: + self.capture_frame.emit(payload) + def _on_comm_rx(self, payload: Any) -> None: if isinstance(payload, (bytes, bytearray)): self._traffic["rx"] += len(payload) @@ -449,6 +686,15 @@ def _on_comm_status(self, payload: Any) -> None: def _on_protocol_frame(self, payload: Any) -> None: self._append_buffer({"kind": "FRAME", "payload": payload, "ts": time.time()}) + def _on_capture_frame(self, payload: Any) -> None: + self._append_buffer({"kind": "CAPTURE", "payload": payload, "ts": time.time()}) + QMetaObject.invokeMethod( + self, + "_emit_capture_frame_signal", + Qt.QueuedConnection, + Q_ARG(object, payload), + ) + def _load_protocols(self) -> None: if self._protocols_loaded: return @@ -471,8 +717,8 @@ def _protocol_category(key: str) -> str: def _settings_defaults(self) -> Dict[str, Any]: base_path = (self._settings_root / "workflows").resolve() return { - "uiLanguage": "简体中文", - "uiTheme": "系统默认", + "uiLanguage": "Simplified Chinese", + "uiTheme": "Dark", "autoConnectOnStart": True, "dslWorkspacePath": str(base_path), "serial": { @@ -518,6 +764,67 @@ def _save_settings(self, payload: Dict[str, Any]) -> bool: return False return True + def _load_proxy_pairs(self) -> List[Dict[str, Any]]: + if not self._proxy_pairs_path.exists(): + return [] + try: + with self._proxy_pairs_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) or [] + except Exception: + return [] + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + + def _save_proxy_pairs(self) -> None: + try: + self._proxy_pairs_path.parent.mkdir(parents=True, exist_ok=True) + with self._proxy_pairs_path.open("w", encoding="utf-8") as handle: + json.dump(self._proxy_pairs, handle, ensure_ascii=False, indent=2) + except Exception as exc: + self.log.emit(f"[WARN] Save proxy pairs failed: {exc}") + + def _load_custom_protocols(self) -> List[Dict[str, Any]]: + if not self._protocols_path.exists(): + return [] + try: + with self._protocols_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) or [] + except Exception: + return [] + if not isinstance(data, list): + return [] + items = [item for item in data if isinstance(item, dict)] + return items + + def _save_custom_protocols(self) -> None: + try: + self._protocols_path.parent.mkdir(parents=True, exist_ok=True) + with self._protocols_path.open("w", encoding="utf-8") as handle: + json.dump(self._custom_protocols, handle, ensure_ascii=False, indent=2) + except Exception as exc: + self.log.emit(f"[WARN] Save protocols failed: {exc}") + + def _normalize_protocol_key(self, value: str) -> str: + value = value.strip().lower().replace(" ", "_") + value = re.sub(r"[^a-z0-9_]+", "_", value) + value = re.sub(r"_+", "_", value).strip("_") + return value or "custom_protocol" + + def _ensure_protocol_key_unique(self, key: str) -> str: + registry_keys = set(ProtocolRegistry.list().keys()) + existing = {item.get("id") for item in self._custom_protocols if isinstance(item, dict)} + existing.update({item.get("key") for item in self._custom_protocols if isinstance(item, dict)}) + existing.update(registry_keys) + if key not in existing: + return key + index = 2 + candidate = f"{key}_{index}" + while candidate in existing: + index += 1 + candidate = f"{key}_{index}" + return candidate + def _append_buffer(self, item: Dict[str, Any]) -> None: self._buffer.append(item) if len(self._buffer) > 2000: diff --git a/ui/web_window.py b/desktop/web_window.py similarity index 56% rename from ui/web_window.py rename to desktop/web_window.py index 634357c..0411157 100644 --- a/ui/web_window.py +++ b/desktop/web_window.py @@ -1,6 +1,7 @@ -from __future__ import annotations +from __future__ import annotations from pathlib import Path +import logging import os import sys @@ -8,17 +9,36 @@ from PySide6.QtCore import QPoint, Qt, QUrl from PySide6.QtGui import QAction, QGuiApplication, QIcon from PySide6.QtWebChannel import QWebChannel + from PySide6.QtWebEngineCore import QWebEnginePage from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu from PySide6.QtWebEngineWidgets import QWebEngineView except ImportError: # pragma: no cover from PyQt6.QtCore import QPoint, Qt, QUrl # type: ignore from PyQt6.QtGui import QAction, QGuiApplication, QIcon # type: ignore from PyQt6.QtWebChannel import QWebChannel # type: ignore + from PyQt6.QtWebEngineCore import QWebEnginePage # type: ignore from PyQt6.QtWidgets import QFileDialog, QMainWindow, QMenu # type: ignore from PyQt6.QtWebEngineWidgets import QWebEngineView # type: ignore -from ui.web_bridge import WebBridge -from ui.win_snap import apply_snap_styles +from desktop.web_bridge import WebBridge +from desktop.win_snap import apply_snap_styles + +class LoggingWebPage(QWebEnginePage): + def javaScriptConsoleMessage(self, level, message, line_number, source_id): # type: ignore[override] + level_map = { + QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: "INFO", + QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "WARN", + QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "ERROR", + } + tag = level_map.get(level, "LOG") + logging.getLogger("web_js").warning( + "[%s] %s (%s:%s)", + tag, + message, + source_id, + line_number, + ) + super().javaScriptConsoleMessage(level, message, line_number, source_id) class WebWindow(QMainWindow): """Minimal WebEngine host window for the new web UI.""" @@ -36,6 +56,8 @@ def __init__(self, bus=None, comm=None) -> None: self.setAttribute(Qt.WA_TranslucentBackground, False) view = QWebEngineView(self) + page = LoggingWebPage(view) + view.setPage(page) self.setCentralWidget(view) channel = QWebChannel(view) @@ -51,7 +73,7 @@ def __init__(self, bus=None, comm=None) -> None: icon = QIcon(str(icon_png)) if not icon.isNull(): self.setWindowIcon(icon) - dist_index = base_dir / "web-ui" / "dist" / "index.html" + dist_index = base_dir / "frontend" / "dist" / "index.html" fallback_index = base_dir / "assets" / "web" / "index.html" index_path = dist_index if dist_index.exists() else fallback_index view.load(QUrl.fromLocalFile(str(index_path))) @@ -61,7 +83,7 @@ def _handle_download(self, item) -> None: suggested = item.downloadFileName() path, _ = QFileDialog.getSaveFileName( self, - "保存日志", + "Save log", suggested or "io_logs.log", "Log Files (*.log);;All Files (*.*)", ) @@ -176,7 +198,7 @@ def _start_resize(self, edge: str) -> None: handle = self.windowHandle() if not handle or not hasattr(handle, "startSystemResize"): return - edge_map = { + resize_edges = { "left": Qt.LeftEdge, "right": Qt.RightEdge, "top": Qt.TopEdge, @@ -186,35 +208,123 @@ def _start_resize(self, edge: str) -> None: "bottom-left": Qt.BottomEdge | Qt.LeftEdge, "bottom-right": Qt.BottomEdge | Qt.RightEdge, } - qt_edge = edge_map.get(edge) - if qt_edge is None: + edge_flag = resize_edges.get(edge) + if not edge_flag: return - handle.startSystemResize(qt_edge) - - def _update_normal_geometry(self) -> None: - if not self.isMaximized() and not self.isMinimized(): - self._normal_geometry = self.geometry() + handle.startSystemResize(edge_flag) def _remember_normal_geometry(self) -> None: - if not self.isMaximized() and not self.isMinimized(): - self._normal_geometry = self.geometry() + self._normal_geometry = self.geometry() def _get_normal_geometry(self): - normal = self._normal_geometry - if normal is None: - normal = self.normalGeometry() - return normal + return self._normal_geometry + + def _toggle_max_restore(self) -> None: + if self.isMaximized(): + self.showNormal() + else: + self._remember_normal_geometry() + self.showMaximized() + + def _mouse_pos(self, event) -> QPoint: + pos = event.globalPosition().toPoint() + return pos + + def _handle_mouse_press(self, event) -> None: + if event.button() == Qt.LeftButton: + pos = self._mouse_pos(event) + widget = self.childAt(event.position().toPoint()) + if widget and widget.property("resize-edge"): + self._start_resize(widget.property("resize-edge")) + return + if self._is_titlebar_area(pos.x(), pos.y()): + self._start_move(pos.x(), pos.y()) + return + if event.button() == Qt.RightButton: + pos = self._mouse_pos(event) + if self._is_titlebar_area(pos.x(), pos.y()): + self._show_system_menu(pos.x(), pos.y()) + return + + def _handle_mouse_double_click(self, event) -> None: + if self._is_titlebar_area(event.position().toPoint().x(), event.position().toPoint().y()): + self._toggle_max_restore() - def resizeEvent(self, event) -> None: # type: ignore[override] - super().resizeEvent(event) - self._update_normal_geometry() + def _handle_mouse_move(self, event) -> None: + if event.buttons() & Qt.LeftButton: + return + pos = event.position().toPoint() + widget = self.childAt(pos) + if widget and widget.property("resize-edge"): + edge = widget.property("resize-edge") + cursor_map = { + "left": Qt.SizeHorCursor, + "right": Qt.SizeHorCursor, + "top": Qt.SizeVerCursor, + "bottom": Qt.SizeVerCursor, + "top-left": Qt.SizeFDiagCursor, + "bottom-right": Qt.SizeFDiagCursor, + "top-right": Qt.SizeBDiagCursor, + "bottom-left": Qt.SizeBDiagCursor, + } + cursor = cursor_map.get(edge, Qt.ArrowCursor) + self.setCursor(cursor) + else: + self.setCursor(Qt.ArrowCursor) + + def _handle_mouse_leave(self, event) -> None: + self.setCursor(Qt.ArrowCursor) + + def _bind_titlebar_events(self): + if hasattr(self, "title_bar"): + self.title_bar.mousePressEvent = self._handle_mouse_press + self.title_bar.mouseDoubleClickEvent = self._handle_mouse_double_click + self.title_bar.mouseMoveEvent = self._handle_mouse_move + self.title_bar.leaveEvent = self._handle_mouse_leave + + def _is_titlebar_area(self, screen_x: int, screen_y: int) -> bool: + if self.isMaximized(): + return screen_y <= self._titlebar_height + frame_y = self.y() + if self._win_style_applied: + frame_y += self._titlebar_height + return screen_y <= frame_y + self._titlebar_height + + def _apply_custom_titlebar(self) -> None: + base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) + style_path = base_dir / "assets" / "styles" / "window.css" + if not style_path.exists(): + return + self.setStyleSheet(style_path.read_text(encoding="utf-8")) + apply_snap_styles(self) + self._win_style_applied = True - def moveEvent(self, event) -> None: # type: ignore[override] - super().moveEvent(event) - self._update_normal_geometry() + def _init_system_titlebar(self): + base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) + html_path = base_dir / "assets" / "titlebar" / "titlebar.html" + if not html_path.exists(): + return + html = html_path.read_text(encoding="utf-8") + view = QWebEngineView(self) + view.setObjectName("title_bar") + view.setFixedHeight(self._titlebar_height) + view.setContextMenuPolicy(Qt.NoContextMenu) + view.setAttribute(Qt.WA_TransparentForMouseEvents, False) + view.load(QUrl.fromLocalFile(str(html_path))) + view.page().setBackgroundColor(Qt.transparent) + view.setStyleSheet("background: transparent") + view.page().setHtml(html) + view.setParent(self) + view.show() + self.title_bar = view + self._bind_titlebar_events() + + def _init_titlebar(self): + if sys.platform == "darwin": + self._init_system_titlebar() + else: + self._apply_custom_titlebar() - def showEvent(self, event) -> None: # type: ignore[override] + def showEvent(self, event): super().showEvent(event) - if sys.platform == "win32" and not self._win_style_applied: - apply_snap_styles(int(self.winId())) - self._win_style_applied = True + self._init_titlebar() diff --git a/ui/widgets/__init__.py b/desktop/widgets/__init__.py similarity index 100% rename from ui/widgets/__init__.py rename to desktop/widgets/__init__.py diff --git a/ui/win_snap.py b/desktop/win_snap.py similarity index 100% rename from ui/win_snap.py rename to desktop/win_snap.py diff --git a/web-ui/.gitignore b/frontend/.gitignore similarity index 100% rename from web-ui/.gitignore rename to frontend/.gitignore diff --git a/web-ui/.vscode/extensions.json b/frontend/.vscode/extensions.json similarity index 100% rename from web-ui/.vscode/extensions.json rename to frontend/.vscode/extensions.json diff --git a/web-ui/README.md b/frontend/README.md similarity index 100% rename from web-ui/README.md rename to frontend/README.md diff --git a/web-ui/config/ui_settings.json b/frontend/config/ui_settings.json similarity index 70% rename from web-ui/config/ui_settings.json rename to frontend/config/ui_settings.json index 29e0d9e..c977230 100644 --- a/web-ui/config/ui_settings.json +++ b/frontend/config/ui_settings.json @@ -1,6 +1,6 @@ { "autoConnectOnStart": false, - "dslWorkspacePath": "D:\\GitRepository\\ProtoFlow\\web-ui\\workflows", + "dslWorkspacePath": "D:\\GitRepository\\ProtoFlow\\frontend\\workflows", "network": { "tcpHeartbeatSec": 60, "tcpRetryCount": 3, @@ -12,5 +12,5 @@ "defaultStopBits": "1" }, "uiLanguage": "English (US)", - "uiTheme": "系统默认" + "uiTheme": "绯荤粺榛樿" } \ No newline at end of file diff --git a/web-ui/index.html b/frontend/index.html similarity index 100% rename from web-ui/index.html rename to frontend/index.html diff --git a/web-ui/package-lock.json b/frontend/package-lock.json similarity index 59% rename from web-ui/package-lock.json rename to frontend/package-lock.json index a6f32fc..dc481b8 100644 --- a/web-ui/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "web-ui", + "name": "protoflow-frontend", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "web-ui", + "name": "protoflow-frontend", "version": "0.0.0", "dependencies": { "@codemirror/lang-yaml": "^6.1.2", @@ -14,13 +14,32 @@ "@element-plus/icons-vue": "^2.3.2", "codemirror": "^6.0.2", "element-plus": "^2.13.0", - "vue": "^3.5.24" + "js-yaml": "^4.1.0", + "pinia": "^2.2.2", + "vue": "^3.5.24", + "zod": "^3.23.8" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "vite": "^7.2.4" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -618,11 +637,43 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lezer/common": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", @@ -664,6 +715,44 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.7", @@ -1056,6 +1145,12 @@ "@vue/shared": "3.5.26" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/@vue/reactivity": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", @@ -1116,32 +1211,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@vueuse/core/node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/@vueuse/metadata": { "version": "10.11.1", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", @@ -1163,38 +1232,235 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@vueuse/shared/node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=12" + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } + "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", "license": "MIT" }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", + "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -1210,12 +1476,35 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1227,6 +1516,27 @@ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.277", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz", + "integrity": "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==", + "dev": true, + "license": "ISC" + }, "node_modules/element-plus": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz", @@ -1304,11 +1614,61 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1326,20 +1686,187 @@ } } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1377,6 +1904,55 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1394,12 +1970,56 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-wheel-es": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "license": "BSD-3-Clause" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1417,6 +2037,48 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1435,6 +2097,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1444,6 +2107,222 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", @@ -1485,6 +2364,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1499,6 +2402,103 @@ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1515,6 +2515,64 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -1609,11 +2667,62 @@ } } }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/web-ui/package.json b/frontend/package.json similarity index 69% rename from web-ui/package.json rename to frontend/package.json index 0f960d6..64c8ecf 100644 --- a/web-ui/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "web-ui", + "name": "protoflow-frontend", "private": true, "version": "0.0.0", "type": "module", @@ -15,10 +15,16 @@ "@element-plus/icons-vue": "^2.3.2", "codemirror": "^6.0.2", "element-plus": "^2.13.0", - "vue": "^3.5.24" + "vue": "^3.5.24", + "pinia": "^2.2.2", + "js-yaml": "^4.1.0", + "zod": "^3.23.8" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "vite": "^7.2.4" } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..ee87759 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web-ui/public/vite.svg b/frontend/public/vite.svg similarity index 100% rename from web-ui/public/vite.svg rename to frontend/public/vite.svg diff --git a/web-ui/src/App.vue b/frontend/src/App.vue similarity index 68% rename from web-ui/src/App.vue rename to frontend/src/App.vue index 1a28cf1..0a99820 100644 --- a/web-ui/src/App.vue +++ b/frontend/src/App.vue @@ -7,8 +7,11 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' import { tags } from '@lezer/highlight' import ManualView from './components/ManualView.vue' import ScriptsView from './components/ScriptsView.vue' +import ProxyMonitorView from './components/ProxyMonitorView.vue' import DropdownSelect from './components/DropdownSelect.vue' import { yaml as yamlLanguage } from '@codemirror/lang-yaml' +import LayoutRenderer from './ui/LayoutRenderer.vue' +import { useUiRuntimeStore } from './stores/uiRuntime' const bridge = ref(null) const sidebarRef = ref(null) @@ -36,6 +39,19 @@ let commLogSeq = 0 let scriptLogSeq = 0 let lastStatusText = '' let hasStatusActivity = false +const captureFrames = ref([]) +const captureMeta = ref({ + channel: '', + engine: '通用动态解析 (Agnostic Engine)', + bufferUsed: 0, + rangeStart: 0, + rangeEnd: 0, + totalFrames: 0, + page: 1, + pageCount: 1, +}) +const captureMetrics = ref({ rtt: '--', loss: '--' }) +const MAX_CAPTURE_FRAMES = 200 const scriptState = ref('idle') const scriptProgress = ref(0) const yamlText = ref('# paste DSL YAML here') @@ -79,8 +95,8 @@ const channelWriteTimeout = ref(1000) const channelHost = ref('127.0.0.1') const channelTcpPort = ref(502) const channelAutoConnect = ref(true) -const uiLanguage = ref('????') -const uiTheme = ref('娴呰壊') +const uiLanguage = ref('zh-CN') +const uiTheme = ref('light') const defaultBaud = ref(115200) const defaultParity = ref('none') const defaultStopBits = ref('1') @@ -92,12 +108,166 @@ const autoConnectOnStart = ref(true) const settingsSaving = ref(false) const settingsSnapshot = ref(null) const settingsTab = ref('general') +const translations = { + 'zh-CN': { + 'nav.manual': '串口终端', + 'nav.scripts': '自动脚本', + 'nav.proxy': '代理监控', + 'nav.protocols': '协议管理', + 'nav.settings': '设置', + 'nav.workspace': '管理员工作区', + 'header.manual.title': '串口终端', + 'header.manual.desc': '传统串口调试工具:发送命令,监听 I/O 与日志回显。', + 'header.scripts.title': '自动脚本', + 'header.scripts.desc': '用于协议解析与测试脚本自动化执行。', + 'header.proxy.title': '代理监控', + 'header.proxy.desc': '管理转发链路并实时监控数据流状态。', + 'header.protocols.title': '协议管理', + 'header.protocols.desc': '配置通信协议定义,绑定通道并设置解析规则。', + 'header.settings.title': '应用设置', + 'header.settings.desc': '管理全局偏好、协议默认值和运行时环境配置。', + 'action.refresh': '刷新', + 'action.createProtocol': '新建协议', + 'action.discardChanges': '放弃更改', + 'action.saveChanges': '保存更改', + 'action.loadScript': '加载脚本', + 'action.save': '保存', + 'badge.readOnly': '只读', + 'action.refreshStatus': '刷新状态', + 'action.newProxy': '新建转发对', + 'filter.all': '全部', + 'filter.running': '运行中', + 'filter.stopped': '已停止', + 'filter.error': '异常', + 'status.connected': '已连接', + 'status.disconnected': '未连接', + 'status.error': '错误', + 'status.connecting': '连接中', + 'action.connect': '连接', + 'action.disconnect': '断开', + 'protocol.tab.all': '全部协议', + 'protocol.tab.modbus': 'Modbus', + 'protocol.tab.tcp': 'TCP/IP', + 'protocol.tab.custom': '自定义', + 'settings.tab.general': '通用', + 'settings.tab.plugins': '插件', + 'settings.tab.runtime': '运行时', + 'settings.tab.logs': '日志', + 'settings.language': '界面语言', + 'settings.theme': '主题偏好', + 'settings.autoConnect.title': '启动时自动连接', + 'settings.autoConnect.desc': '自动尝试重连上次活动的通道。', + 'settings.workspace': '工作目录', + 'settings.chooseFolder': '选择目录', + 'settings.plugins.title': '插件管理', + 'settings.plugins.refresh': '刷新列表', + 'lang.zhCN': '简体中文', + 'lang.enUS': 'English (US)', + 'theme.system': '系统默认', + 'theme.dark': '深色 (工程模式)', + 'theme.light': '浅色', + }, + 'en-US': { + 'nav.manual': 'Serial Terminal', + 'nav.scripts': 'Scripts', + 'nav.proxy': 'Proxy Monitor', + 'nav.protocols': 'Protocol Manager', + 'nav.settings': 'Settings', + 'nav.workspace': 'Admin Workspace', + 'header.manual.title': 'Serial Terminal', + 'header.manual.desc': 'Classic serial console for sending commands, monitoring I/O and logs.', + 'header.scripts.title': 'Automation Scripts', + 'header.scripts.desc': 'Run parsing and test automation scripts.', + 'header.proxy.title': 'Proxy Monitor', + 'header.proxy.desc': 'Manage forwarding links and monitor data streams.', + 'header.protocols.title': 'Protocols', + 'header.protocols.desc': 'Define protocols, bind channels, and configure parsing rules.', + 'header.settings.title': 'App Settings', + 'header.settings.desc': 'Manage global preferences, defaults, and runtime configuration.', + 'action.refresh': 'Refresh', + 'action.createProtocol': 'New Protocol', + 'action.discardChanges': 'Discard', + 'action.saveChanges': 'Save Changes', + 'action.loadScript': 'Load Script', + 'action.save': 'Save', + 'badge.readOnly': 'Read-only', + 'action.refreshStatus': 'Refresh Status', + 'action.newProxy': 'New Forward Pair', + 'filter.all': 'All', + 'filter.running': 'Running', + 'filter.stopped': 'Stopped', + 'filter.error': 'Error', + 'status.connected': 'Connected', + 'status.disconnected': 'Disconnected', + 'status.error': 'Error', + 'status.connecting': 'Connecting', + 'action.connect': 'Connect', + 'action.disconnect': 'Disconnect', + 'protocol.tab.all': 'All Protocols', + 'protocol.tab.modbus': 'Modbus', + 'protocol.tab.tcp': 'TCP/IP', + 'protocol.tab.custom': 'Custom', + 'settings.tab.general': 'General', + 'settings.tab.plugins': 'Plugins', + 'settings.tab.runtime': 'Runtime', + 'settings.tab.logs': 'Logs', + 'settings.language': 'Language', + 'settings.theme': 'Theme', + 'settings.autoConnect.title': 'Auto-connect on launch', + 'settings.autoConnect.desc': 'Reconnect to the last active channel automatically.', + 'settings.workspace': 'Workspace', + 'settings.chooseFolder': 'Choose Folder', + 'settings.plugins.title': 'Plugins', + 'settings.plugins.refresh': 'Refresh List', + 'lang.zhCN': '简体中文', + 'lang.enUS': 'English (US)', + 'theme.system': 'System', + 'theme.dark': 'Dark (Engineer)', + 'theme.light': 'Light', + }, +} + +const t = (key, fallback = '') => { + const lang = uiLanguage.value || 'zh-CN' + return translations[lang]?.[key] ?? translations['zh-CN']?.[key] ?? fallback ?? key +} + +const tr = (text) => { + const lang = uiLanguage.value || 'zh-CN' + const key = String(text ?? '') + return translations[lang]?.[key] ?? translations['zh-CN']?.[key] ?? key +} + +const uiLabels = computed(() => ({ + manual: t('nav.manual'), + scripts: t('nav.scripts'), + proxy: t('nav.proxy'), + protocols: t('nav.protocols'), + settings: t('nav.settings'), + workspace: t('nav.workspace'), +})) const channelTab = ref('all') const protocolTab = ref('all') +const protocolDialogOpen = ref(false) +const protocolDialogMode = ref('create') +const protocolEditing = ref(null) +const protocolDeleteOpen = ref(false) +const protocolDeleting = ref(null) +const protocolDraft = ref({ + id: '', + key: '', + name: '', + desc: '', + category: 'custom', + status: 'custom', +}) const settingsGeneralRef = ref(null) const settingsPluginsRef = ref(null) const settingsRuntimeRef = ref(null) const settingsLogsRef = ref(null) +const uiRuntime = useUiRuntimeStore() +const uiModalOpen = ref(false) +const appVersion = ref('') const noPorts = computed(() => ports.value.length === 0) const portOptionsList = computed(() => ports.value.map((item) => ({ label: item, value: item, icon: 'usb' }))) @@ -128,19 +298,19 @@ const channelCards = computed(() => { const type = channel.type || 'unknown' const status = channel.status || 'disconnected' const statusMap = { - connected: { text: '已连接', className: 'status-ok' }, - connecting: { text: '连接中', className: 'status-warn' }, - error: { text: '错误', className: 'status-error' }, - disconnected: { text: '未连接', className: 'status-idle' }, - idle: { text: '空闲', className: 'status-idle' }, + connected: { text: t('status.connected'), className: 'status-ok' }, + connecting: { text: t('status.connecting'), className: 'status-warn' }, + error: { text: t('status.error'), className: 'status-error' }, + disconnected: { text: t('status.disconnected'), className: 'status-idle' }, + idle: { text: tr('空闲'), className: 'status-idle' }, } const statusInfo = statusMap[status] || statusMap.disconnected const isSerial = type === 'serial' const isTcpClient = type === 'tcp-client' - const name = isSerial ? '串口通道' : isTcpClient ? 'TCP 客户端' : 'TCP 服务端' + const name = isSerial ? tr('串口通道') : isTcpClient ? tr('TCP 客户端') : tr('TCP 服务端') const details = isSerial ? [channel.port || '--', channel.baud ? `${channel.baud} bps` : '--'] - : [channel.host || channel.address || '--', channel.port ? `端口: ${channel.port}` : '--'] + : [channel.host || channel.address || '--', channel.port ? `${tr('端口')}: ${channel.port}` : '--'] const traffic = `TX: ${formatBytes(channel.tx_bytes || 0)} / RX: ${formatBytes(channel.rx_bytes || 0)}` return { id: channel.id || `${type}:${details[0]}`, @@ -187,12 +357,22 @@ const scriptCanRun = computed(() => !scriptRunning.value && yamlText.value.trim( const scriptCanStop = computed(() => scriptRunning.value) const scriptStatusLabel = computed(() => { if (scriptRunning.value) { - return scriptState.value ? '运行中 ' + scriptState.value : '运行中' + return scriptState.value ? `${tr('运行中')} ${scriptState.value}` : tr('运行中') } - return '空闲' + return tr('空闲') }) const scriptStatusClass = computed(() => (scriptRunning.value ? 'running' : 'idle')) const quickPayloadCount = computed(() => countQuickPayload(quickDraft.value.payload, quickDraft.value.mode)) +const languageOptions = computed(() => [ + { value: 'zh-CN', label: t('lang.zhCN') }, + { value: 'en-US', label: t('lang.enUS') }, +]) +const themeOptions = computed(() => [ + { value: 'system', label: t('theme.system') }, + { value: 'dark', label: t('theme.dark') }, + { value: 'light', label: t('theme.light') }, +]) +const appVersionLabel = computed(() => appVersion.value || 'v0.0.0') const filteredChannelCards = computed(() => { if (channelTab.value === 'all') return channelCards.value @@ -294,6 +474,9 @@ const scriptsViewBindings = { provide('manualView', manualViewBindings) provide('scriptsView', scriptsViewBindings) +provide('bridge', bridge) +provide('t', t) +provide('tr', tr) let scriptTimer = null let yamlEditor = null @@ -303,6 +486,42 @@ let channelRefreshTimer = null let snapPreviewRaf = 0 let pendingSnapPreview = null let attachedBridge = null +const SCROLL_LOCK_SELECTORS = [ + '.page', + '.modal-body', + '.log-stream', + '.quick-list', + '.sidebar-nav', + '.select-menu', + '.proxy-modal-list', +] +let scrollLockSnapshot = [] + +function preventScroll(event) { + if (!event) return + event.preventDefault() +} + +function lockPageScroll() { + const selector = SCROLL_LOCK_SELECTORS.join(', ') + const nodes = document.querySelectorAll(selector) + scrollLockSnapshot = Array.from(nodes).map((node) => { + return { el: node, top: node.scrollTop || 0 } + }) + document.addEventListener('wheel', preventScroll, { passive: false }) + document.addEventListener('touchmove', preventScroll, { passive: false }) +} + +function unlockPageScroll() { + scrollLockSnapshot.forEach((item) => { + if (item.el) { + item.el.scrollTop = item.top + } + }) + scrollLockSnapshot = [] + document.removeEventListener('wheel', preventScroll) + document.removeEventListener('touchmove', preventScroll) +} function filterCommLogs(lines, tab, keyword) { if (tab === 'tcp') return [] @@ -504,6 +723,64 @@ function scheduleLogFlush() { }) } +function formatCaptureTime(ts) { + const date = new Date((ts || 0) * 1000) + const pad = (value, length = 2) => String(value).padStart(length, '0') + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad( + date.getMilliseconds(), + 3 + )}` +} + +function mapCaptureFrame(payload) { + if (!payload || typeof payload !== 'object') return null + const protocol = payload.protocol || {} + const unknown = Boolean(protocol.unknown) + const direction = payload.direction || 'RX' + const tone = unknown ? 'red' : direction === 'TX' ? 'blue' : 'green' + return { + id: payload.id || `cap-${Date.now()}`, + direction, + time: formatCaptureTime(payload.timestamp || Date.now() / 1000), + size: payload.length || 0, + note: payload.raw_hex || '', + summary: payload.summary || '', + summaryText: payload.summary || '', + tone, + warn: unknown, + channel: payload.channel || '', + baud: payload.baud || '', + protocolLabel: protocol.name || (unknown ? 'Unknown' : ''), + protocolType: unknown + ? 'unknown' + : (protocol.name || '').toLowerCase().includes('modbus') + ? 'modbus' + : 'custom', + protocolTooltip: protocol.name ? `${protocol.name}${protocol.version ? ` ${protocol.version}` : ''}` : '', + hexDump: payload.hex_dump || null, + tree: payload.tree || [], + } +} + +function ingestCaptureFrame(payload) { + const frame = mapCaptureFrame(payload) + if (!frame) return + captureFrames.value.push(frame) + if (captureFrames.value.length > MAX_CAPTURE_FRAMES) { + captureFrames.value.splice(0, captureFrames.value.length - MAX_CAPTURE_FRAMES) + } + captureMeta.value.totalFrames += 1 + captureMeta.value.rangeEnd = captureMeta.value.totalFrames + captureMeta.value.rangeStart = Math.max(1, captureMeta.value.totalFrames - captureFrames.value.length + 1) + captureMeta.value.bufferUsed = Math.min( + 100, + Math.round((captureFrames.value.length / MAX_CAPTURE_FRAMES) * 100) + ) + if (payload && payload.channel) { + captureMeta.value.channel = payload.channel + } +} + function flushLogs() { if (commLogBuffer.length) { const batch = commLogBuffer.splice(0, commLogBuffer.length) @@ -684,7 +961,7 @@ function saveQuickCommand() { const name = String(quickDraft.value.name || '').trim() const payload = String(quickDraft.value.payload || '').trim() if (!name || !payload) { - window.alert('请输入指令名称和内容') + window.alert(tr('请输入指令名称和内容')) return } const record = { @@ -816,7 +1093,7 @@ function protocolCategory(key) { function prettyProtocolName(key, fallback) { const value = String(key || "").trim() - if (!value) return fallback || "协议" + if (!value) return fallback || tr('协议') const parts = value.split("_").map((part) => { const upper = part.toUpperCase() if (["RTU", "TCP", "SCPI", "AT", "XMODEM", "YMODEM"].includes(upper)) return upper @@ -826,25 +1103,45 @@ function prettyProtocolName(key, fallback) { return parts.join(" ") } +function protocolStatusInfo(status) { + if (status === "available") { + return { text: tr('可用'), className: 'badge-green' } + } + if (status === "custom") { + return { text: tr('自定义'), className: 'badge-blue' } + } + if (status === "disabled") { + return { text: tr('已禁用'), className: 'badge-gray' } + } + return { text: status || tr('未知'), className: 'badge-gray' } +} + function setProtocols(items) { const list = Array.isArray(items) ? items : [] protocolCards.value = list.map((item) => { const key = String(item.key || item.id || "") - const driver = String(item.driver || item.name || "") + const driver = String(item.driver || "") + const name = String(item.name || "") const category = String(item.category || protocolCategory(key)) const status = String(item.status || "available") - const desc = String(item.desc || "暂无描述") + const source = String(item.source || "builtin") + const desc = String(item.desc || "") + const statusInfo = protocolStatusInfo(status) return { id: key || driver || Math.random().toString(36).slice(2), - name: prettyProtocolName(key, driver), + key, + name: name || prettyProtocolName(key, driver), + driver, category, desc, - statusText: status === "available" ? "可用" : status, - statusClass: status === "available" ? "badge-green" : "badge-gray", + statusText: statusInfo.text, + statusClass: statusInfo.className, + status, + source, rows: [ - { label: "键名", value: key || "--" }, - { label: "驱动", value: driver || "--" }, - { label: "分类", value: category || "--" }, + { label: tr('键名'), value: key || '--' }, + { label: tr('驱动'), value: driver || '--' }, + { label: tr('分类'), value: category || '--' }, ], } }) @@ -857,6 +1154,98 @@ function refreshProtocols() { }) } +function resetProtocolDraft() { + protocolDraft.value = { + id: "", + key: "", + name: "", + desc: "", + category: "custom", + status: "custom", + } +} + +function openCreateProtocol() { + protocolDialogMode.value = "create" + protocolEditing.value = null + resetProtocolDraft() + protocolDialogOpen.value = true +} + +function openProtocolDetails(card) { + if (!card) return + protocolEditing.value = card + protocolDialogMode.value = card.source === "custom" ? "edit" : "view" + protocolDraft.value = { + id: card.id || "", + key: card.key || "", + name: card.name || "", + desc: card.desc || "", + category: card.category || "custom", + status: card.status || "available", + } + protocolDialogOpen.value = true +} + +function closeProtocolDialog() { + protocolDialogOpen.value = false +} + +function saveProtocol() { + if (!bridge.value) { + protocolDialogOpen.value = false + return + } + const payload = { + id: protocolDraft.value.id, + key: protocolDraft.value.key, + name: protocolDraft.value.name, + desc: protocolDraft.value.desc, + category: protocolDraft.value.category, + status: protocolDraft.value.status, + } + if (protocolDialogMode.value === "create") { + if (!bridge.value.create_protocol) return + withResult(bridge.value.create_protocol(payload), () => { + refreshProtocols() + protocolDialogOpen.value = false + }) + return + } + if (protocolDialogMode.value === "edit") { + if (!bridge.value.update_protocol) return + withResult(bridge.value.update_protocol(payload), () => { + refreshProtocols() + protocolDialogOpen.value = false + }) + return + } + protocolDialogOpen.value = false +} + +function openProtocolDelete(card) { + if (!card || card.source !== "custom") return + protocolDeleting.value = card + protocolDeleteOpen.value = true +} + +function closeProtocolDelete() { + protocolDeleteOpen.value = false + protocolDeleting.value = null +} + +function confirmProtocolDelete() { + if (!bridge.value || !bridge.value.delete_protocol || !protocolDeleting.value) { + closeProtocolDelete() + return + } + const id = protocolDeleting.value.id + withResult(bridge.value.delete_protocol(id), () => { + refreshProtocols() + closeProtocolDelete() + }) +} + function handleChannelRefresh() { refreshChannels() @@ -988,6 +1377,18 @@ function sendQuickCommand(cmd) { bridge.value.send_text(data) } +async function openUiYamlModal() { + if (!uiModalOpen.value) { + uiModalOpen.value = true + } + uiRuntime.yamlText = yamlText.value + await uiRuntime._parseWithBridge() +} + +function closeUiYamlModal() { + uiModalOpen.value = false +} + function runScript() { if (!bridge.value) return const payload = yamlText.value.trim() @@ -1000,6 +1401,7 @@ function runScript() { scriptStartMs.value = Date.now() scriptElapsedMs.value = 0 scriptProgress.value = 0 + openUiYamlModal() bridge.value.run_script(payload) } @@ -1095,7 +1497,7 @@ async function copyYaml() { } function searchYaml() { - const keyword = window.prompt('搜索关键词') + const keyword = window.prompt(tr('搜索关键词')) if (!keyword) return if (yamlEditor) { const doc = yamlEditor.state.doc.toString() @@ -1142,6 +1544,13 @@ function attachBridge(obj) { if (!obj || attachedBridge === obj) return attachedBridge = obj bridge.value = obj + if (obj.get_app_version) { + withResult(obj.get_app_version(), (value) => { + if (value) { + appVersion.value = String(value).trim() + } + }) + } if (obj.comm_rx && obj.comm_tx) { obj.comm_rx.connect((payload) => { const parsed = parseBridgePayload(payload) @@ -1160,6 +1569,13 @@ function attachBridge(obj) { addCommLog('FRAME', { text: JSON.stringify(payload), ts }) }) } + if (obj.capture_frame) { + obj.capture_frame.connect((payload) => { + const ts = payload && payload.ts ? payload.ts : Date.now() / 1000 + addCommLog('CAPTURE', { text: JSON.stringify(payload), ts }) + ingestCaptureFrame(payload) + }) + } obj.comm_status.connect((payload) => { const detail = payload && payload.payload !== undefined ? payload.payload : payload const ts = payload && payload.ts ? payload.ts : Date.now() / 1000 @@ -1287,12 +1703,27 @@ watch( ) watch( - () => channelDialogOpen.value || quickDialogOpen.value || quickDeleteOpen.value, + () => + channelDialogOpen.value || + quickDialogOpen.value || + quickDeleteOpen.value || + protocolDialogOpen.value || + protocolDeleteOpen.value || + uiModalOpen.value, (open) => { document.body.classList.toggle('modal-open', open) } ) +watch( + () => scriptRunning.value, + (running) => { + if (running && !uiModalOpen.value) { + openUiYamlModal() + } + } +) + watch( () => yamlText.value, (value) => { @@ -1340,6 +1771,7 @@ function maybeStartWindowMove(event) { dragStarted.value = true draggingWindow.value = true document.body.classList.add('dragging-window') + lockPageScroll() snapPreview.value = '' attachDragListeners() } @@ -1378,6 +1810,7 @@ function showSystemMenu(event) { function startResize(edge, event) { if (!bridge.value || !edge || !event) return document.body.classList.add('resizing') + lockPageScroll() bridge.value.window_start_resize(edge) } @@ -1408,6 +1841,23 @@ const settingsDirty = computed(() => { return JSON.stringify(buildSettingsPayload()) !== JSON.stringify(settingsSnapshot.value) }) +function normalizeLanguage(value) { + const raw = String(value || '') + const lowered = raw.toLowerCase() + if (raw === 'zh-CN' || lowered === 'zh-cn' || raw === '简体中文') return 'zh-CN' + if (raw === 'en-US' || lowered === 'en-us' || raw === 'English (US)') return 'en-US' + return 'zh-CN' +} + +function normalizeTheme(value) { + const raw = String(value || '') + const lowered = raw.toLowerCase() + if (raw === 'system' || lowered === 'system' || raw === '系统默认') return 'system' + if (raw === 'dark' || lowered === 'dark' || raw === '深色 (工程模式)') return 'dark' + if (raw === 'light' || lowered === 'light' || raw === '浅色') return 'light' + return 'light' +} + function buildSettingsPayload() { return { uiLanguage: uiLanguage.value, @@ -1430,8 +1880,8 @@ function buildSettingsPayload() { function normalizeSettings(payload) { const defaults = { - uiLanguage: '????', - uiTheme: '????', + uiLanguage: 'zh-CN', + uiTheme: 'light', autoConnectOnStart: true, dslWorkspacePath: '/usr/local/protoflow/workflows', quickCommands: quickCommands.value, @@ -1450,6 +1900,8 @@ function normalizeSettings(payload) { return { ...defaults, ...payload, + uiLanguage: normalizeLanguage(payload.uiLanguage), + uiTheme: normalizeTheme(payload.uiTheme), serial: { ...defaults.serial, ...(payload.serial || {}), @@ -1513,7 +1965,7 @@ function discardSettings() { function chooseDslWorkspace() { if (!bridge.value || !bridge.value.select_directory) return withResult( - bridge.value.select_directory('????', dslWorkspacePath.value || ''), + bridge.value.select_directory(tr('选择工作区'), dslWorkspacePath.value || ''), (value) => { if (value) { dslWorkspacePath.value = value @@ -1602,6 +2054,7 @@ function clearDragState() { } document.body.classList.remove('dragging-window') document.body.classList.remove('resizing') + unlockPageScroll() detachDragListeners() } @@ -1685,38 +2138,38 @@ function unlockSidebarWidth() { hub
-
ProtoFlow
-
v2.4.0-stable
+
ProtoFlow
+
{{ appVersionLabel }}
@@ -1724,107 +2177,42 @@ function unlockSidebarWidth() {
- -
- -
- - - - -
-
-
-
-
- settings_input_hdmi -
-
-
- {{ card.name }} - {{ card.type }} -
-
- {{ card.details[0] }} - - {{ card.details[1] }} -
-
-
-
-
- 流量 - {{ card.traffic }} -
-
- - {{ card.statusText }} -
- -
-
-
-
-
- link_off -
-
-
暂无通道
-
- 未检测到活动连接 -
-
-
-
-
-
+
- + - +
{{ card.name }}
-
{{ card.desc }}
+
{{ card.desc || tr('暂无描述') }}
{{ card.statusText }}
@@ -1835,68 +2223,72 @@ function unlockSidebarWidth() {
- - +
- add + inventory_2
-

从模板创建

-

使用预设的 Modbus、MQTT 或 TCP 模板快速开始。

+

{{ tr('暂无协议') }}

+

{{ tr('暂无可用协议,可从内置模板创建或新增自定义协议。') }}

+
-
+
- - - - + + + +
- tune - 通用 + tune{{ t('settings.tab.general') }}
- 启动时自动连接 -

自动尝试重连上次活动的通道。

+ {{ t('settings.autoConnect.title') }} +

{{ t('settings.autoConnect.desc') }}

- extension - 插件 + extension{{ t('settings.tab.plugins') }}
- 插件管理 + {{ t('settings.plugins.title') }}
Modbus TCP/RTU
-
v1.2.4 - 已启用
+
{{ tr('v1.2.4 - 已启用') }}
- 已启用 + {{ tr('已启用') }}
-
MQTT 适配器
-
v0.9.8 - 未安装
+
{{ tr('MQTT 适配器') }}
+
{{ tr('v0.9.8 - 未安装') }}
- 未安装 + {{ tr('未安装') }}
- 启动时自动连接 -

自动尝试重连上次活动的通道。

+ {{ t('settings.autoConnect.title') }} +

{{ t('settings.autoConnect.desc') }}

@@ -1979,8 +2368,8 @@ function unlockSidebarWidth() {
+
+ + + + + -
+ + + + + + +
+ + diff --git a/web-ui/src/assets/fonts/MaterialSymbolsOutlined.ttf b/frontend/src/assets/fonts/MaterialSymbolsOutlined.ttf similarity index 100% rename from web-ui/src/assets/fonts/MaterialSymbolsOutlined.ttf rename to frontend/src/assets/fonts/MaterialSymbolsOutlined.ttf diff --git a/web-ui/src/assets/vue.svg b/frontend/src/assets/vue.svg similarity index 100% rename from web-ui/src/assets/vue.svg rename to frontend/src/assets/vue.svg diff --git a/web-ui/src/components/DropdownSelect.vue b/frontend/src/components/DropdownSelect.vue similarity index 95% rename from web-ui/src/components/DropdownSelect.vue rename to frontend/src/components/DropdownSelect.vue index b8894a9..c18e23b 100644 --- a/web-ui/src/components/DropdownSelect.vue +++ b/frontend/src/components/DropdownSelect.vue @@ -78,7 +78,7 @@ function toggle() { setOpenId(dropdownId) document.body.classList.add('dropdown-open') window.dispatchEvent(new CustomEvent('dropdown:open', { detail: dropdownId })) - nextTick(() => updateMenuPosition()) + nextTick(() => scheduleMenuPosition()) } function close() { @@ -114,6 +114,12 @@ function updateMenuPosition() { } } +function scheduleMenuPosition() { + if (!open.value) return + requestAnimationFrame(() => updateMenuPosition()) + requestAnimationFrame(() => updateMenuPosition()) +} + function handlePointerDown(event) { if (!rootRef.value || !event) return if ( @@ -133,7 +139,7 @@ function handleKeydown(event) { function handleViewportChange() { if (!open.value) return - updateMenuPosition() + scheduleMenuPosition() } function handleExternalOpen(event) { diff --git a/web-ui/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue similarity index 100% rename from web-ui/src/components/HelloWorld.vue rename to frontend/src/components/HelloWorld.vue diff --git a/web-ui/src/components/LogStream.vue b/frontend/src/components/LogStream.vue similarity index 100% rename from web-ui/src/components/LogStream.vue rename to frontend/src/components/LogStream.vue diff --git a/web-ui/src/components/ManualView.vue b/frontend/src/components/ManualView.vue similarity index 76% rename from web-ui/src/components/ManualView.vue rename to frontend/src/components/ManualView.vue index a6dee97..f6cf16d 100644 --- a/web-ui/src/components/ManualView.vue +++ b/frontend/src/components/ManualView.vue @@ -3,6 +3,8 @@ import { inject } from 'vue' import DropdownSelect from './DropdownSelect.vue' import LogStream from './LogStream.vue' +const t = inject('t', (key) => key) +const tr = inject('tr', (text) => text) const bindings = inject('manualView') if (!bindings) { throw new Error('manualView bindings not provided') @@ -63,17 +65,16 @@ const {