From 03c83d0636996af298602a08f60a442530bcc75c Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 01:11:16 +0800 Subject: [PATCH 001/145] fix(ui): close functional gaps for proxy monitor and runtime paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 改动: - 代理监控页移除默认演示数据,改为后端数据驱动,并动态加载串口端口列表。 - 新增代理配置保存校验(端口必填、端口不可相同)与表单错误提示。 - 修复资源/版本路径与打包数据(含 VERSION)以提升打包运行一致性。 - 协议解析订阅改为 comm.rx,抓包引擎补充连接类型兼容处理。 - 前端复制能力增加通用降级实现,移除过时 TODO 注释。 - 影响范围: - 代理监控、打包后运行资源定位、版本显示、抓包链路、复制交互。 - 兼容性/行为变化: - 代理页初始不再展示硬编码示例数据;仅展示真实配置数据。 - 依赖/环境: - 无新增外部依赖。 - 验证: - 已执行 `ui/frontend` 下 `npm run build` 并通过。 [English] - Changes: - Reworked proxy monitor to use backend-driven data and dynamic serial port options. - Added validation and inline error feedback for proxy pair save actions. - Fixed runtime resource/version path resolution and included VERSION in packaging inputs. - Switched protocol parsing subscription to `comm.rx` and aligned capture channel type handling. - Added clipboard fallback helper and removed stale TODOs. - Impact: - Proxy monitor behavior, packaged runtime consistency, capture pipeline, and copy UX. - Compatibility/Behavior changes: - Proxy monitor no longer shows hardcoded demo items by default. - Dependencies/Environment: - No new external dependencies. - Verification: - Ran `npm run build` in `ui/frontend` successfully. Refs: - .where-agent-progress.md --- .where-agent-progress.md | 51 +++++++ app/packet_engine.py | 4 +- infra/protocol/protocol_loader.py | 2 +- scripts/build_windows.ps1 | 1 + ui/desktop/web_bridge.py | 24 +++- ui/desktop/web_window.py | 69 +++++++-- .../src/components/ProxyMonitorView.vue | 133 +++++++++--------- .../src/components/ui-kit/EventLogPanel.vue | 7 +- .../src/components/ui-kit/InspectorJSON.vue | 7 +- ui/frontend/src/ui/LayoutRenderer.vue | 1 - ui/frontend/src/utils/clipboard.ts | 30 ++++ 11 files changed, 232 insertions(+), 97 deletions(-) create mode 100644 .where-agent-progress.md create mode 100644 ui/frontend/src/utils/clipboard.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md new file mode 100644 index 0000000..99b6bd2 --- /dev/null +++ b/.where-agent-progress.md @@ -0,0 +1,51 @@ +# 计划:ProtoFlow 功能闭环(当前阶段不做 TCP) + +## 0. 范围确认 +- [x] 只做功能闭环,不处理商店物料/营销 +- [x] 基于现有 UI 和功能打磨到可稳定交付 +- [x] 本阶段排除 TCP 功能开发 +- [x] 代理监控纳入 P0 主线 + +## 1. 已完成(第 1 轮) +- [x] 代理监控移除默认演示数据,改为后端真实数据驱动 +- [x] 代理端口改为动态读取 `list_ports`(保留回退端口) +- [x] 代理配置保存前增加校验(端口必填、端口不可相同) +- [x] 代理参数 `parity` 统一标准枚举并兼容历史值映射 +- [x] 抓包引擎连接类型兼容修正(`tcp`/`tcp-client`) +- [x] 前端构建校验通过(`npm run build`) + +## 2. 当前阻塞 +- [!] 代理监控尚未实现“串口对串口转发引擎”(`hostPort <-> devicePort`) + +## 3. P0 下一阶段(现在开始) +- [ ] 3.1 实现串口转发引擎(双端口桥接) + - [ ] 3.1.1 新增转发会话管理:创建、启动、停止、释放 + - [ ] 3.1.2 支持 A->B / B->A 双向转发 + - [ ] 3.1.3 端口占用、断连、权限异常时回传错误事件 + - [ ] 3.1.4 与现有 `proxy_pair_status` 同步(running/stopped/error) + +- [ ] 3.2 打通 UI 与转发状态 + - [ ] 3.2.1 开关操作真正驱动后端启动/停止 + - [ ] 3.2.2 错误状态在卡片和弹窗中可见 + - [ ] 3.2.3 删除代理前确保会话停止并资源释放 + +- [ ] 3.3 代理抓包与转发联动 + - [ ] 3.3.1 采集仅在对应代理通道生效 + - [ ] 3.3.2 停止采集后不再追加帧 + - [ ] 3.3.3 采集数据与代理状态一致 + +## 4. P0 其余项(转发引擎后) +- [ ] 4.1 串口链路闭环(连接/收发/断开/异常) +- [ ] 4.2 协议解析闭环(正常帧与异常帧提示) +- [ ] 4.3 DSL 运行闭环(启动/停止/异常回传) +- [ ] 4.4 配置持久化闭环(重启一致、损坏回退) +- [ ] 4.5 打包运行一致性闭环(资源/版本/日志) + +## 5. P1(交互闭环) +- [ ] 页面无半成品入口或死按钮 +- [ ] 组件行为完整(输入/多选/动作按钮) +- [ ] 复制与导出具备降级路径 + +## 6. 交付物 +- [ ] 输出《功能验收清单(可逐项打勾)》 +- [ ] 输出《P0 回归用例(可复现步骤+预期)》 diff --git a/app/packet_engine.py b/app/packet_engine.py index ecf694c..26d1713 100644 --- a/app/packet_engine.py +++ b/app/packet_engine.py @@ -69,8 +69,8 @@ def _on_connected(self, payload: Any) -> None: 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 "" + elif payload.get("type") in {"tcp", "tcp-client"}: + self._channel.channel = str(self._channel.address or "") def _on_disconnected(self, payload: Any) -> None: self._channel = _ChannelInfo() diff --git a/infra/protocol/protocol_loader.py b/infra/protocol/protocol_loader.py index dcac738..51e969a 100644 --- a/infra/protocol/protocol_loader.py +++ b/infra/protocol/protocol_loader.py @@ -58,7 +58,7 @@ def __init__(self, bus: EventBus, config_path: str | Path = "config/protocol.yam self.load_config() # 订阅串口接收事件 if self._enabled: - self.bus.subscribe("serial.rx", self.parse) + self.bus.subscribe("comm.rx", self.parse) def load_config(self) -> None: """加载 YAML 配置,并缓存关键字段。""" diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 6f857c4..a62c961 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -21,6 +21,7 @@ Write-Host "==> Build app (PyInstaller)" --add-data "config;config" ` --add-data "plugins;plugins" ` --add-data "ui\\assets;ui\\assets" ` + --add-data "VERSION;." ` main.py Write-Host "==> Generate installer icon" diff --git a/ui/desktop/web_bridge.py b/ui/desktop/web_bridge.py index f32cbf2..cb2eacb 100644 --- a/ui/desktop/web_bridge.py +++ b/ui/desktop/web_bridge.py @@ -9,6 +9,7 @@ from pathlib import Path import logging import os +import sys from typing import Any, Dict, List, Optional import yaml @@ -90,10 +91,27 @@ def _read_app_version(self) -> str: env_version = os.environ.get("PROTOFLOW_VERSION") if env_version: return env_version.strip() + candidates = [ + Path.cwd() / "VERSION", + Path(__file__).resolve().parents[2] / "VERSION", + ] + if getattr(sys, "frozen", False): + exe_dir = Path(sys.executable).resolve().parent + candidates.extend( + [ + exe_dir / "VERSION", + exe_dir / "_internal" / "VERSION", + ] + ) + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + candidates.append(Path(meipass) / "VERSION") try: - version_path = Path(__file__).resolve().parents[1] / "VERSION" - if version_path.is_file(): - return version_path.read_text(encoding="utf-8").strip() + for version_path in candidates: + if version_path.is_file(): + version = version_path.read_text(encoding="utf-8").strip() + if version: + return version except Exception: return "v0.0.0" return "v0.0.0" diff --git a/ui/desktop/web_window.py b/ui/desktop/web_window.py index e31a618..81c8b36 100644 --- a/ui/desktop/web_window.py +++ b/ui/desktop/web_window.py @@ -65,20 +65,63 @@ def __init__(self, bus=None, comm=None) -> None: channel.registerObject("bridge", self.bridge) view.page().setWebChannel(channel) - base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) - icon_svg = base_dir / "assets" / "icons" / "logo.svg" - icon_png = base_dir / "assets" / "icons" / "logo.png" + resource_root = self._resolve_resource_root() + icon_svg = self._find_existing_path( + resource_root / "assets" / "icons" / "logo.svg", + resource_root / "ui" / "assets" / "icons" / "logo.svg", + ) + icon_png = self._find_existing_path( + resource_root / "assets" / "icons" / "logo.png", + resource_root / "ui" / "assets" / "icons" / "logo.png", + ) icon = QIcon(str(icon_svg)) if icon.isNull(): icon = QIcon(str(icon_png)) if not icon.isNull(): self.setWindowIcon(icon) - 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 + index_path = self._find_existing_path( + resource_root / "frontend" / "dist" / "index.html", + resource_root / "ui" / "frontend" / "dist" / "index.html", + resource_root / "assets" / "web" / "index.html", + resource_root / "ui" / "assets" / "web" / "index.html", + ) view.load(QUrl.fromLocalFile(str(index_path))) view.page().profile().downloadRequested.connect(self._handle_download) + @staticmethod + def _find_existing_path(*candidates: Path) -> Path: + for path in candidates: + if path.exists(): + return path + return candidates[0] + + def _resolve_resource_root(self) -> Path: + source_ui_dir = Path(__file__).resolve().parents[1] + if not getattr(sys, "frozen", False): + return source_ui_dir + + candidates = [] + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + candidates.append(Path(meipass)) + candidates.append(Path(meipass) / "ui") + + exe_dir = Path(sys.executable).resolve().parent + candidates.append(exe_dir) + candidates.append(exe_dir / "_internal") + candidates.append(exe_dir / "ui") + + for root in candidates: + if (root / "frontend" / "dist" / "index.html").exists(): + return root + if (root / "ui" / "frontend" / "dist" / "index.html").exists(): + return root + if (root / "assets" / "web" / "index.html").exists(): + return root + if (root / "ui" / "assets" / "web" / "index.html").exists(): + return root + return candidates[0] if candidates else source_ui_dir + def _handle_download(self, item) -> None: suggested = item.downloadFileName() path, _ = QFileDialog.getSaveFileName( @@ -291,8 +334,11 @@ def _is_titlebar_area(self, screen_x: int, screen_y: int) -> bool: 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" + resource_root = self._resolve_resource_root() + style_path = self._find_existing_path( + resource_root / "assets" / "styles" / "window.css", + resource_root / "ui" / "assets" / "styles" / "window.css", + ) if not style_path.exists(): return self.setStyleSheet(style_path.read_text(encoding="utf-8")) @@ -300,8 +346,11 @@ def _apply_custom_titlebar(self) -> None: self._win_style_applied = True def _init_system_titlebar(self): - base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) - html_path = base_dir / "assets" / "titlebar" / "titlebar.html" + resource_root = self._resolve_resource_root() + html_path = self._find_existing_path( + resource_root / "assets" / "titlebar" / "titlebar.html", + resource_root / "ui" / "assets" / "titlebar" / "titlebar.html", + ) if not html_path.exists(): return html = html_path.read_text(encoding="utf-8") diff --git a/ui/frontend/src/components/ProxyMonitorView.vue b/ui/frontend/src/components/ProxyMonitorView.vue index a470b99..5442bc7 100644 --- a/ui/frontend/src/components/ProxyMonitorView.vue +++ b/ui/frontend/src/components/ProxyMonitorView.vue @@ -29,7 +29,7 @@ const connectionMode = ref(tr('透传模式')) const hostPort = ref('COM3') const devicePort = ref('COM5') const baudRate = ref('115200') -const parity = ref(tr('无')) +const parity = ref('none') const dataBits = ref('8') const stopBits = ref('1') const flowControl = ref('none') @@ -39,76 +39,23 @@ const connectionOptions = computed(() => [ { value: '协议桥接', label: tr('协议桥接') }, { value: '映射模式', label: tr('映射模式') }, ]) -const portOptions = ['COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM10', 'COM12'] +const serialPorts = ref([]) +const fallbackPorts = ['COM1', 'COM2', 'COM3', 'COM4'] +const portOptions = computed(() => { + const ports = (serialPorts.value || []).filter(Boolean) + return ports.length ? ports : fallbackPorts +}) const baudOptions = ['4800', '9600', '19200', '38400', '57600', '115200'] const parityOptions = computed(() => [ - { value: '无', label: tr('无') }, - { value: '偶校验', label: tr('偶校验') }, - { value: '奇校验', label: tr('奇校验') }, - { value: 'Mark', label: 'Mark' }, - { value: 'Space', label: 'Space' }, + { value: 'none', label: tr('无') }, + { value: 'even', label: tr('偶校验') }, + { value: 'odd', label: tr('奇校验') }, + { value: 'mark', label: 'Mark' }, + { value: 'space', label: 'Space' }, ]) - -const proxies = ref([ - { - id: 'proxy-com3', - name: tr('主控制器链路'), - meta: 'ID: PX-00124 · 8-N-1', - status: 'running', - statusLabel: tr('运行中'), - statusIcon: 'swap_horizontal_circle', - routeIcon: 'keyboard_double_arrow_right', - routeLabel: tr('转发中'), - routeTone: 'primary', - hostPort: 'COM3', - devicePort: 'COM5', - baud: '115200', - bandwidth: '12.4', - bandwidthUnit: 'KB/s', - spark: 'M0 35 L10 20 L20 35 L30 10 L40 30 L50 5 L60 35 L70 20 L80 30 L90 10 L100 25', - active: true, - toggleLabel: tr('运行中'), - }, - { - id: 'proxy-com7', - name: tr('电机反馈继电器'), - meta: 'ID: PX-00992 · 8-E-1', - status: 'stopped', - statusLabel: tr('已停止'), - statusIcon: 'pause_circle', - routeIcon: 'more_horiz', - routeLabel: tr('离线'), - routeTone: 'muted', - hostPort: 'COM7', - devicePort: 'COM10', - baud: '9600', - bandwidth: '0.0', - bandwidthUnit: 'KB/s', - spark: '', - active: false, - toggleLabel: tr('已停止'), - }, - { - id: 'proxy-com1', - name: tr('GPS 模块数据流'), - meta: 'ID: PX-00219 · 7-N-2', - status: 'error', - statusLabel: tr('异常'), - statusIcon: 'report', - routeIcon: 'sync_problem', - routeLabel: tr('连接失败'), - routeTone: 'danger', - hostPort: 'COM1', - devicePort: 'COM12', - baud: '4800', - bandwidth: '0.4', - bandwidthUnit: 'KB/s', - spark: 'M0 38 L40 38 L42 10 L48 10 L50 38 L90 38 L92 10 L98 10 L100 38', - active: true, - toggleLabel: tr('异常'), - }, -]) +const proxies = ref([]) +const formError = ref('') let proxySeq = 1000 @@ -143,6 +90,15 @@ function mapProxyFromBackend(payload) { const routeIcon = active ? 'keyboard_double_arrow_right' : 'more_horiz' const baud = payload.baud ? String(payload.baud) : '115200' proxySeq = Math.max(proxySeq, Number(String(payload.id || '').replace(/\D/g, '')) || proxySeq) + const parityMap = { + 无: 'none', + 偶校验: 'even', + 奇校验: 'odd', + Mark: 'mark', + Space: 'space', + } + const parityValue = String(payload.parity || 'none') + const normalizedParity = parityMap[parityValue] || parityValue return { id: payload.id || `proxy-${Date.now()}`, name: payload.name || tr('未命名转发对'), @@ -158,7 +114,7 @@ function mapProxyFromBackend(payload) { baud, dataBits: payload.dataBits || '8', stopBits: payload.stopBits || '1', - parity: payload.parity || 'none', + parity: normalizedParity, flowControl: payload.flowControl || 'none', bandwidth: payload.bandwidth || '0.0', bandwidthUnit: payload.bandwidthUnit || 'KB/s', @@ -176,6 +132,20 @@ function loadProxyPairs() { }) } +function loadSerialPorts() { + if (!bridge || !bridge.value || !bridge.value.list_ports) { + serialPorts.value = [] + return + } + withBridgeResult(bridge.value.list_ports(), (items) => { + if (!Array.isArray(items)) { + serialPorts.value = [] + return + } + serialPorts.value = items.filter((item) => typeof item === 'string' && item.trim()) + }) +} + const filteredProxies = computed(() => { const safeProxies = proxies.value.filter(Boolean) if (activeFilter.value === 'all') return safeProxies @@ -315,6 +285,7 @@ const filteredFrames = computed(() => { }) function openEditModal(proxy) { + formError.value = '' modalMode.value = 'edit' modalProxy.value = proxy proxyName.value = proxy && proxy.name ? proxy.name : '' @@ -329,11 +300,13 @@ function openEditModal(proxy) { } function openCreateModal() { + formError.value = '' modalMode.value = 'create' modalProxy.value = null proxyName.value = '' - hostPort.value = portOptions[0] || 'COM1' - devicePort.value = portOptions[1] || 'COM2' + const options = portOptions.value + hostPort.value = options[0] || 'COM1' + devicePort.value = options[1] || options[0] || 'COM2' baudRate.value = baudOptions[5] || '115200' dataBits.value = '8' stopBits.value = '1' @@ -357,6 +330,7 @@ function openCaptureModal(proxy) { } function closeModal() { + formError.value = '' modalOpen.value = false } @@ -372,6 +346,7 @@ function selectFrame(frame) { } function refreshProxies() { + loadSerialPorts() if (bridge && bridge.value && bridge.value.refresh_proxy_pairs) { withBridgeResult(bridge.value.refresh_proxy_pairs(), (items) => { if (!Array.isArray(items)) return @@ -382,6 +357,16 @@ function refreshProxies() { proxies.value = proxies.value.map((proxy) => ({ ...proxy })) } +function validateProxyPayload(payload) { + if (!payload.hostPort || !payload.devicePort) { + return tr('请选择主机端口和设备端口') + } + if (payload.hostPort === payload.devicePort) { + return tr('主机端口和设备端口不能相同') + } + return '' +} + function setProxyStatus(proxy, active) { const nextStatus = active ? 'running' : 'stopped' const statusLabel = active ? tr('运行中') : tr('已停止') @@ -418,6 +403,12 @@ function saveProxy() { parity: parity.value, flowControl: flowControl.value, } + const validationError = validateProxyPayload(payload) + if (validationError) { + formError.value = validationError + return + } + formError.value = '' if (modalMode.value === 'create') { if (bridge && bridge.value && bridge.value.create_proxy_pair) { @@ -512,6 +503,7 @@ function confirmDeleteProxy(proxy) { } onMounted(() => { + loadSerialPorts() loadProxyPairs() }) @@ -707,6 +699,7 @@ onBeforeUnmount(() => { +
{{ formError }}
settings_ethernet{{ tr('串口参数配置') }}{{ tr('(两端需一致)') }} diff --git a/ui/frontend/src/components/ui-kit/EventLogPanel.vue b/ui/frontend/src/components/ui-kit/EventLogPanel.vue index 159333f..35b9c3e 100644 --- a/ui/frontend/src/components/ui-kit/EventLogPanel.vue +++ b/ui/frontend/src/components/ui-kit/EventLogPanel.vue @@ -13,6 +13,7 @@ diff --git a/ui/frontend/src/components/ui-kit/InspectorJSON.vue b/ui/frontend/src/components/ui-kit/InspectorJSON.vue index cc94fd7..55c404b 100644 --- a/ui/frontend/src/components/ui-kit/InspectorJSON.vue +++ b/ui/frontend/src/components/ui-kit/InspectorJSON.vue @@ -12,6 +12,7 @@ diff --git a/ui/frontend/src/ui/LayoutRenderer.vue b/ui/frontend/src/ui/LayoutRenderer.vue index 94ee0aa..d7a6df8 100644 --- a/ui/frontend/src/ui/LayoutRenderer.vue +++ b/ui/frontend/src/ui/LayoutRenderer.vue @@ -93,7 +93,6 @@ const LayoutNodeRenderer = defineComponent({ if (widget.type === 'action.button') { const payload = widget.props?.payload || { source: widget.id } store.dispatchEvent({ emit: widget.emit || 'action.unknown', payload, source: widget.id }) - // TODO: sendEventToBackend } }, }, diff --git a/ui/frontend/src/utils/clipboard.ts b/ui/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..54b5ee1 --- /dev/null +++ b/ui/frontend/src/utils/clipboard.ts @@ -0,0 +1,30 @@ +export async function copyText(text: string): Promise { + if (!text) return true + + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + // Fallback below. + } + + try { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + textarea.style.pointerEvents = 'none' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + const ok = document.execCommand('copy') + document.body.removeChild(textarea) + return ok + } catch { + return false + } +} From 27abadd906b421f78030604f382406bc84e5dd9a Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 01:15:24 +0800 Subject: [PATCH 002/145] chore(repo): ignore AGENTS.md from git tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 改动: - 在 .gitignore 中新增 AGENTS.md 忽略规则。 - 影响范围: - 仅影响 Git 跟踪行为,不影响运行时功能。 - 兼容性/行为变化: - AGENTS.md 不再出现在未跟踪文件列表。 - 依赖/环境: - 无。 - 验证: - 已通过 git status --short 验证。 [English] - Changes: - Added AGENTS.md ignore rule in .gitignore. - Impact: - Git tracking behavior only; no runtime impact. - Compatibility/Behavior changes: - AGENTS.md no longer appears as an untracked file. - Dependencies/Environment: - None. - Verification: - Verified with git status --short. Refs: - AGENTS.md --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2bf21d7..3b3f87f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ Thumbs.db build/ dist/ ProtoFlow.spec +AGENTS.md From 229604b8df064b92b2119a33e3ad684a6224239d Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 01:20:57 +0800 Subject: [PATCH 003/145] feat(runtime): close proxy monitor forwarding loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - 新增串口代理转发管理器,支持 hostPort/devicePort 双向桥接与会话生命周期管理 - WebBridge 接入 proxy manager,代理开关/删除/更新行为与真实会话联动 - 监听 proxy.status 回写代理对状态,错误信息持久化 - 抓包引擎接入 proxy.data,将代理转发流量转为 capture.frame - 同步更新 Where 计划文档,标记本轮闭环进展 - 影响范围: - 代理监控从 UI 假状态切换为后端真实运行状态 - 抓包面板可采集代理转发链路数据 - 兼容性/行为变化: - 本阶段仍不涉及 TCP 开发,仅覆盖串口代理链路 - 依赖/环境: - 依赖 pyserial 进行串口转发 - 验证: - 使用 python -m py_compile 校验相关 Python 文件语法通过 - 运行 npm run build 构建前端通过 [English] - Changes: - Added a serial proxy forward manager for bidirectional host/device relay. - Integrated WebBridge proxy controls with real start/stop session actions. - Persist proxy.status updates back into pair status/error fields. - Wired proxy.data into packet capture pipeline as capture.frame events. - Updated Where plan doc with current closure progress. - Impact: - Proxy monitor now reflects runtime state instead of static UI flags. - Capture view can ingest forwarded proxy traffic. - Compatibility/Behavior changes: - No TCP feature changes in this phase; serial proxy path only. - Dependencies/Environment: - Requires pyserial for forwarding sessions. - Verification: - Python syntax check passed via py_compile. - Frontend build passed via npm run build. Refs: - .where-agent-progress.md --- .where-agent-progress.md | 52 +++---- app/main_web.py | 4 +- app/packet_engine.py | 36 ++++- infra/comm/proxy_forward_manager.py | 211 ++++++++++++++++++++++++++++ ui/desktop/web_bridge.py | 49 ++++++- ui/desktop/web_window.py | 4 +- 6 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 infra/comm/proxy_forward_manager.py diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 99b6bd2..107e8ed 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -2,7 +2,7 @@ ## 0. 范围确认 - [x] 只做功能闭环,不处理商店物料/营销 -- [x] 基于现有 UI 和功能打磨到可稳定交付 +- [x] 基于现有 UI 与功能打磨到可稳定交付 - [x] 本阶段排除 TCP 功能开发 - [x] 代理监控纳入 P0 主线 @@ -14,38 +14,32 @@ - [x] 抓包引擎连接类型兼容修正(`tcp`/`tcp-client`) - [x] 前端构建校验通过(`npm run build`) -## 2. 当前阻塞 -- [!] 代理监控尚未实现“串口对串口转发引擎”(`hostPort <-> devicePort`) - -## 3. P0 下一阶段(现在开始) -- [ ] 3.1 实现串口转发引擎(双端口桥接) - - [ ] 3.1.1 新增转发会话管理:创建、启动、停止、释放 - - [ ] 3.1.2 支持 A->B / B->A 双向转发 - - [ ] 3.1.3 端口占用、断连、权限异常时回传错误事件 - - [ ] 3.1.4 与现有 `proxy_pair_status` 同步(running/stopped/error) - -- [ ] 3.2 打通 UI 与转发状态 - - [ ] 3.2.1 开关操作真正驱动后端启动/停止 - - [ ] 3.2.2 错误状态在卡片和弹窗中可见 - - [ ] 3.2.3 删除代理前确保会话停止并资源释放 - -- [ ] 3.3 代理抓包与转发联动 - - [ ] 3.3.1 采集仅在对应代理通道生效 - - [ ] 3.3.2 停止采集后不再追加帧 - - [ ] 3.3.3 采集数据与代理状态一致 - -## 4. P0 其余项(转发引擎后) +## 2. 当前进展(第 2 轮) +- [x] 2.1 实现串口转发引擎(双串口桥接) + - [x] 2.1.1 新增转发会话管理:创建、启动、停止、释放 + - [x] 2.1.2 支持 A->B / B->A 双向转发 + - [x] 2.1.3 端口占用、断连、权限异常时回传错误事件 +- [x] 2.2 打通 UI 与转发状态 + - [x] 2.2.1 开关操作真实驱动后端启动/停止 + - [x] 2.2.2 删除代理前先停止会话并释放资源 + - [x] 2.2.3 监听 `proxy.status` 并回写 `running/stopped/error` +- [x] 2.3 代理抓包联动 + - [x] 2.3.1 转发数据发布 `proxy.data` + - [x] 2.3.2 抓包引擎订阅 `proxy.data` 并产出 `capture.frame` + +## 3. P0 下一阶段(待做) +- [ ] 3.1 代理抓包方向与通道语义再校准(RX/TX 与目标通道映射) +- [ ] 3.2 代理异常可视化增强(卡片级错误详情与重试引导) +- [ ] 3.3 代理状态持久化策略优化(运行中崩溃恢复/冷启动一致性) +- [ ] 3.4 真实设备回归:双串口回环、异常拔插、长时稳定性 + +## 4. P0 其余项(转发闭环后) - [ ] 4.1 串口链路闭环(连接/收发/断开/异常) - [ ] 4.2 协议解析闭环(正常帧与异常帧提示) - [ ] 4.3 DSL 运行闭环(启动/停止/异常回传) - [ ] 4.4 配置持久化闭环(重启一致、损坏回退) - [ ] 4.5 打包运行一致性闭环(资源/版本/日志) -## 5. P1(交互闭环) -- [ ] 页面无半成品入口或死按钮 -- [ ] 组件行为完整(输入/多选/动作按钮) -- [ ] 复制与导出具备降级路径 - -## 6. 交付物 +## 5. 交付物 - [ ] 输出《功能验收清单(可逐项打勾)》 -- [ ] 输出《P0 回归用例(可复现步骤+预期)》 +- [ ] 输出《P0 回归用例(可复现步骤 + 预期)》 diff --git a/app/main_web.py b/app/main_web.py index e2e8ee2..dc25f7c 100644 --- a/app/main_web.py +++ b/app/main_web.py @@ -18,6 +18,7 @@ from PyQt6.QtWidgets import QApplication # type: ignore from infra.comm.communication_manager import CommunicationManager +from infra.comm.proxy_forward_manager import ProxyForwardManager from infra.common.event_bus import EventBus from app.packet_engine import PacketAnalysisEngine from app.plugin_manager import PluginManager @@ -170,13 +171,14 @@ def main() -> None: os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", flags) bus = EventBus() comm = CommunicationManager(bus) + proxy_manager = ProxyForwardManager(bus) protocol = ProtocolLoader(bus) packet_engine = PacketAnalysisEngine(bus) plugins = PluginManager(bus, protocol=protocol) plugins.load_all() app = QApplication.instance() or QApplication(sys.argv) - window = WebWindow(bus=bus, comm=comm) + window = WebWindow(bus=bus, comm=comm, proxy_manager=proxy_manager) window.show() print("ProtoFlow Web UI started") app.exec() diff --git a/app/packet_engine.py b/app/packet_engine.py index 26d1713..90108dd 100644 --- a/app/packet_engine.py +++ b/app/packet_engine.py @@ -29,7 +29,7 @@ class PacketAnalysisEngine: def __init__(self, bus: EventBus) -> None: self._bus = bus - self._queue: "queue.Queue[Tuple[str, bytes, float]]" = queue.Queue() + self._queue: "queue.Queue[Tuple[str, bytes, float, str]]" = queue.Queue() self._channel = _ChannelInfo() self._enabled = False self._target_channel: Optional[str] = None @@ -42,6 +42,7 @@ def __init__(self, bus: EventBus) -> None: self._bus.subscribe("comm.connected", self._on_connected) self._bus.subscribe("comm.disconnected", self._on_disconnected) self._bus.subscribe("capture.control", self._on_control) + self._bus.subscribe("proxy.data", self._on_proxy_data) def _on_rx(self, payload: Any) -> None: if not self._enabled: @@ -50,7 +51,7 @@ def _on_rx(self, payload: Any) -> None: 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())) + self._queue.put(("RX", data, time.time(), self._channel.channel or "")) def _on_tx(self, payload: Any) -> None: if not self._enabled: @@ -59,7 +60,28 @@ def _on_tx(self, payload: Any) -> None: 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())) + self._queue.put(("TX", data, time.time(), self._channel.channel or "")) + + def _on_proxy_data(self, payload: Any) -> None: + if not self._enabled: + return + if not isinstance(payload, dict): + return + data = self._to_bytes(payload.get("data")) + if not data: + return + src = str(payload.get("src") or "") + dst = str(payload.get("dst") or "") + if self._target_channel and self._target_channel not in {src, dst}: + return + if self._target_channel: + direction = "TX" if src == self._target_channel else "RX" + channel = self._target_channel + else: + direction = "TX" + channel = src + ts = float(payload.get("ts") or time.time()) + self._queue.put((direction, data, ts, channel)) def _on_connected(self, payload: Any) -> None: if isinstance(payload, dict): @@ -90,20 +112,20 @@ def _on_control(self, payload: Any) -> None: def _run(self) -> None: while not self._stop.is_set(): try: - direction, data, ts = self._queue.get(timeout=0.2) + direction, data, ts, channel = self._queue.get(timeout=0.2) except queue.Empty: continue - frame = self._build_frame(direction, data, ts) + frame = self._build_frame(direction, data, ts, channel) self._bus.publish("capture.frame", frame) self._queue.task_done() - def _build_frame(self, direction: str, data: bytes, ts: float) -> Dict[str, Any]: + def _build_frame(self, direction: str, data: bytes, ts: float, channel_override: str = "") -> 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 "" + channel = channel_override or self._channel.channel or "" frame_id = f"{direction.lower()}-{int(ts * 1000)}-{self._counter}" return { "id": frame_id, diff --git a/infra/comm/proxy_forward_manager.py b/infra/comm/proxy_forward_manager.py new file mode 100644 index 0000000..342f231 --- /dev/null +++ b/infra/comm/proxy_forward_manager.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import serial +from serial import SerialException + +from infra.common.event_bus import EventBus + + +_PARITY_MAP = { + "none": serial.PARITY_NONE, + "even": serial.PARITY_EVEN, + "odd": serial.PARITY_ODD, + "mark": serial.PARITY_MARK, + "space": serial.PARITY_SPACE, + "无": serial.PARITY_NONE, + "偶校验": serial.PARITY_EVEN, + "奇校验": serial.PARITY_ODD, +} + +_BYTESIZE_MAP = { + "5": serial.FIVEBITS, + "6": serial.SIXBITS, + "7": serial.SEVENBITS, + "8": serial.EIGHTBITS, +} + +_STOPBITS_MAP = { + "1": serial.STOPBITS_ONE, + "1.5": serial.STOPBITS_ONE_POINT_FIVE, + "2": serial.STOPBITS_TWO, +} + + +@dataclass(frozen=True) +class StartResult: + ok: bool + error: str = "" + + +class _ProxySession: + def __init__(self, bus: EventBus, pair_id: str, config: Dict[str, Any]) -> None: + self._bus = bus + self.pair_id = pair_id + self.host_port = str(config.get("hostPort") or "").strip() + self.device_port = str(config.get("devicePort") or "").strip() + self._baud = int(config.get("baud") or 115200) + self._data_bits = _BYTESIZE_MAP.get(str(config.get("dataBits") or "8"), serial.EIGHTBITS) + parity_key = str(config.get("parity") or "none").strip().lower() + self._parity = _PARITY_MAP.get(parity_key, serial.PARITY_NONE) + self._stop_bits = _STOPBITS_MAP.get(str(config.get("stopBits") or "1"), serial.STOPBITS_ONE) + self._flow = str(config.get("flowControl") or "none").strip().lower() + self._rtscts = self._flow == "rtscts" + self._xonxoff = self._flow == "xonxoff" + self._host_ser: Optional[serial.Serial] = None + self._device_ser: Optional[serial.Serial] = None + self._threads: list[threading.Thread] = [] + self._running = threading.Event() + + def start(self) -> StartResult: + if not self.host_port or not self.device_port: + return StartResult(False, "host/device port is required") + if self.host_port == self.device_port: + return StartResult(False, "host and device port cannot be the same") + try: + self._host_ser = serial.Serial( + port=self.host_port, + baudrate=self._baud, + bytesize=self._data_bits, + parity=self._parity, + stopbits=self._stop_bits, + timeout=0.1, + write_timeout=0.5, + rtscts=self._rtscts, + xonxoff=self._xonxoff, + ) + self._device_ser = serial.Serial( + port=self.device_port, + baudrate=self._baud, + bytesize=self._data_bits, + parity=self._parity, + stopbits=self._stop_bits, + timeout=0.1, + write_timeout=0.5, + rtscts=self._rtscts, + xonxoff=self._xonxoff, + ) + except Exception as exc: + self.stop() + return StartResult(False, str(exc)) + + self._running.set() + self._threads = [ + threading.Thread( + target=self._relay_loop, + args=(self._host_ser, self._device_ser, self.host_port, self.device_port), + daemon=True, + ), + threading.Thread( + target=self._relay_loop, + args=(self._device_ser, self._host_ser, self.device_port, self.host_port), + daemon=True, + ), + ] + for thread in self._threads: + thread.start() + return StartResult(True) + + def stop(self) -> None: + self._running.clear() + for thread in self._threads: + if thread.is_alive(): + thread.join(timeout=1.0) + self._threads = [] + for ser in (self._host_ser, self._device_ser): + if ser is not None: + try: + ser.close() + except Exception: + pass + self._host_ser = None + self._device_ser = None + + def _relay_loop(self, src: serial.Serial, dst: serial.Serial, src_port: str, dst_port: str) -> None: + while self._running.is_set(): + try: + waiting = src.in_waiting if src.is_open else 0 + if waiting <= 0: + time.sleep(0.01) + continue + data = src.read(waiting) + if not data: + continue + dst.write(data) + self._bus.publish( + "proxy.data", + { + "pair_id": self.pair_id, + "src": src_port, + "dst": dst_port, + "data": data, + "ts": time.time(), + }, + ) + except (SerialException, OSError) as exc: + self._bus.publish( + "proxy.status", + { + "pair_id": self.pair_id, + "status": "error", + "error": str(exc), + }, + ) + self._running.clear() + except Exception as exc: + self._bus.publish( + "proxy.status", + { + "pair_id": self.pair_id, + "status": "error", + "error": str(exc), + }, + ) + self._running.clear() + + +class ProxyForwardManager: + def __init__(self, bus: EventBus) -> None: + self._bus = bus + self._lock = threading.RLock() + self._sessions: Dict[str, _ProxySession] = {} + + def start_pair(self, pair_id: str, config: Dict[str, Any]) -> StartResult: + if not pair_id: + return StartResult(False, "pair_id is required") + with self._lock: + old = self._sessions.pop(pair_id, None) + if old is not None: + old.stop() + session = _ProxySession(self._bus, pair_id, config) + result = session.start() + if not result.ok: + self._bus.publish( + "proxy.status", + {"pair_id": pair_id, "status": "error", "error": result.error}, + ) + return result + self._sessions[pair_id] = session + self._bus.publish("proxy.status", {"pair_id": pair_id, "status": "running", "error": None}) + return StartResult(True) + + def stop_pair(self, pair_id: str) -> None: + if not pair_id: + return + with self._lock: + session = self._sessions.pop(pair_id, None) + if session is not None: + session.stop() + self._bus.publish("proxy.status", {"pair_id": pair_id, "status": "stopped", "error": None}) + + def stop_all(self) -> None: + with self._lock: + sessions = list(self._sessions.items()) + self._sessions.clear() + for pair_id, session in sessions: + session.stop() + self._bus.publish("proxy.status", {"pair_id": pair_id, "status": "stopped", "error": None}) diff --git a/ui/desktop/web_bridge.py b/ui/desktop/web_bridge.py index cb2eacb..2cca78b 100644 --- a/ui/desktop/web_bridge.py +++ b/ui/desktop/web_bridge.py @@ -42,12 +42,13 @@ class WebBridge(QObject): channel_update = Signal(object) ui_event_log = Signal(object) - def __init__(self, bus=None, comm=None, window=None) -> None: + def __init__(self, bus=None, comm=None, window=None, proxy_manager=None) -> None: super().__init__() self._logger = logging.getLogger("web_bridge") self._bus = bus self._comm = comm self._window = window + self._proxy_manager = proxy_manager self._script_runner: Optional[ScriptRunnerQt] = None self._buffer: List[Dict[str, Any]] = [] self._protocols_loaded = False @@ -86,6 +87,7 @@ def __init__(self, bus=None, comm=None, window=None) -> None: 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) + self._bus.subscribe("proxy.status", self._on_proxy_status) def _read_app_version(self) -> str: env_version = os.environ.get("PROTOFLOW_VERSION") @@ -312,6 +314,7 @@ def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: return {} for idx, pair in enumerate(self._proxy_pairs): if pair.get("id") == pair_id: + was_running = str(pair.get("status") or "").lower() == "running" updated = { **pair, **{ @@ -332,6 +335,12 @@ def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: }, } self._proxy_pairs[idx] = updated + if was_running and self._proxy_manager: + result = self._proxy_manager.start_pair(pair_id, updated) + if not result.ok: + updated["status"] = "error" + updated["error"] = result.error + self._proxy_pairs[idx] = updated self._save_proxy_pairs() return updated return {} @@ -340,6 +349,8 @@ def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: def delete_proxy_pair(self, pair_id: str) -> bool: if not pair_id: return False + if self._proxy_manager: + self._proxy_manager.stop_pair(pair_id) 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: @@ -353,7 +364,19 @@ def set_proxy_pair_status(self, pair_id: str, active: bool) -> Dict[str, Any]: for idx, pair in enumerate(self._proxy_pairs): if pair.get("id") == pair_id: pair = dict(pair) - pair["status"] = status + if active and self._proxy_manager: + result = self._proxy_manager.start_pair(pair_id, pair) + if not result.ok: + pair["status"] = "error" + pair["error"] = result.error + else: + pair["status"] = status + pair["error"] = None + else: + if self._proxy_manager: + self._proxy_manager.stop_pair(pair_id) + pair["status"] = status + pair["error"] = None self._proxy_pairs[idx] = pair self._save_proxy_pairs() return pair @@ -723,6 +746,28 @@ def _on_capture_frame(self, payload: Any) -> None: Q_ARG(object, payload), ) + def _on_proxy_status(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + pair_id = payload.get("pair_id") + if not pair_id: + return + status = payload.get("status") + error = payload.get("error") + changed = False + for idx, pair in enumerate(self._proxy_pairs): + if pair.get("id") != pair_id: + continue + new_pair = dict(pair) + if status: + new_pair["status"] = status + new_pair["error"] = error or None + self._proxy_pairs[idx] = new_pair + changed = True + break + if changed: + self._save_proxy_pairs() + def _load_protocols(self) -> None: if self._protocols_loaded: return diff --git a/ui/desktop/web_window.py b/ui/desktop/web_window.py index 81c8b36..491e45a 100644 --- a/ui/desktop/web_window.py +++ b/ui/desktop/web_window.py @@ -43,7 +43,7 @@ def javaScriptConsoleMessage(self, level, message, line_number, source_id): # t class WebWindow(QMainWindow): """Minimal WebEngine host window for the new web UI.""" - def __init__(self, bus=None, comm=None) -> None: + def __init__(self, bus=None, comm=None, proxy_manager=None) -> None: super().__init__() self.setWindowTitle("ProtoFlow Web UI") self.resize(1200, 800) @@ -61,7 +61,7 @@ def __init__(self, bus=None, comm=None) -> None: self.setCentralWidget(view) channel = QWebChannel(view) - self.bridge = WebBridge(bus=bus, comm=comm, window=self) + self.bridge = WebBridge(bus=bus, comm=comm, window=self, proxy_manager=proxy_manager) channel.registerObject("bridge", self.bridge) view.page().setWebChannel(channel) From 567499cd7dca6fbcb05d0ccff081bd83fa2236fd Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 01:25:00 +0800 Subject: [PATCH 004/145] fix(runtime): stabilize proxy capture direction semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - proxy.data 事件新增 src_role/dst_role 与 host_port/device_port 字段 - 抓包引擎优先按角色判定 RX/TX,避免无目标通道时方向误判 - 未指定目标通道时,抓包通道优先归一为 host_port - 更新 Where 计划,标记 3.1 已完成 - 影响范围: - 代理抓包列表与方向图标语义更稳定,跨代理场景更一致 - 兼容性/行为变化: - 保持原有 target_channel 过滤逻辑不变 - 依赖/环境: - 无新增依赖 - 验证: - py_compile 通过(proxy_forward_manager.py, packet_engine.py) - npm run build 通过 [English] - Changes: - Added src_role/dst_role and host_port/device_port in proxy.data events. - Prefer role-based RX/TX mapping in capture to avoid direction drift. - Normalize capture channel to host_port when no target channel is set. - Updated Where plan to mark item 3.1 complete. - Impact: - Proxy capture direction and channel semantics are now consistent. - Compatibility/Behavior changes: - Existing target_channel filtering behavior remains unchanged. - Dependencies/Environment: - No new dependencies. - Verification: - py_compile passed for modified Python files. - Frontend build passed via npm run build. Refs: - .where-agent-progress.md --- .where-agent-progress.md | 31 ++++++++++++++++------------ app/packet_engine.py | 15 +++++++++++--- infra/comm/proxy_forward_manager.py | 32 ++++++++++++++++++++++++++--- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 107e8ed..887d026 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -14,7 +14,7 @@ - [x] 抓包引擎连接类型兼容修正(`tcp`/`tcp-client`) - [x] 前端构建校验通过(`npm run build`) -## 2. 当前进展(第 2 轮) +## 2. 已完成(第 2 轮) - [x] 2.1 实现串口转发引擎(双串口桥接) - [x] 2.1.1 新增转发会话管理:创建、启动、停止、释放 - [x] 2.1.2 支持 A->B / B->A 双向转发 @@ -27,19 +27,24 @@ - [x] 2.3.1 转发数据发布 `proxy.data` - [x] 2.3.2 抓包引擎订阅 `proxy.data` 并产出 `capture.frame` -## 3. P0 下一阶段(待做) -- [ ] 3.1 代理抓包方向与通道语义再校准(RX/TX 与目标通道映射) -- [ ] 3.2 代理异常可视化增强(卡片级错误详情与重试引导) -- [ ] 3.3 代理状态持久化策略优化(运行中崩溃恢复/冷启动一致性) -- [ ] 3.4 真实设备回归:双串口回环、异常拔插、长时稳定性 +## 3. 已推进(第 3 轮) +- [x] 3.1 代理抓包方向与通道语义校准(RX/TX 与目标通道映射) + - [x] 3.1.1 `proxy.data` 增加 `src_role/dst_role`(host/device) + - [x] 3.1.2 抓包方向优先按角色判定,避免无目标通道时方向漂移 + - [x] 3.1.3 抓包通道在未指定目标时优先归一到 `host_port` -## 4. P0 其余项(转发闭环后) -- [ ] 4.1 串口链路闭环(连接/收发/断开/异常) -- [ ] 4.2 协议解析闭环(正常帧与异常帧提示) -- [ ] 4.3 DSL 运行闭环(启动/停止/异常回传) -- [ ] 4.4 配置持久化闭环(重启一致、损坏回退) -- [ ] 4.5 打包运行一致性闭环(资源/版本/日志) +## 4. P0 下一阶段(待做) +- [ ] 4.1 代理异常可视化增强(卡片级错误详情与重试引导) +- [ ] 4.2 代理状态持久化策略优化(运行中崩溃恢复/冷启动一致性) +- [ ] 4.3 真实设备回归:双串口回环、异常拔插、长时稳定性 -## 5. 交付物 +## 5. P0 其余项(转发闭环后) +- [ ] 5.1 串口链路闭环(连接/收发/断开/异常) +- [ ] 5.2 协议解析闭环(正常帧与异常帧提示) +- [ ] 5.3 DSL 运行闭环(启动/停止/异常回传) +- [ ] 5.4 配置持久化闭环(重启一致、损坏回退) +- [ ] 5.5 打包运行一致性闭环(资源/版本/日志) + +## 6. 交付物 - [ ] 输出《功能验收清单(可逐项打勾)》 - [ ] 输出《P0 回归用例(可复现步骤 + 预期)》 diff --git a/app/packet_engine.py b/app/packet_engine.py index 90108dd..340798a 100644 --- a/app/packet_engine.py +++ b/app/packet_engine.py @@ -74,12 +74,21 @@ def _on_proxy_data(self, payload: Any) -> None: dst = str(payload.get("dst") or "") if self._target_channel and self._target_channel not in {src, dst}: return - if self._target_channel: + src_role = str(payload.get("src_role") or "").lower() + if src_role == "host": + direction = "TX" + elif src_role == "device": + direction = "RX" + elif self._target_channel: direction = "TX" if src == self._target_channel else "RX" - channel = self._target_channel else: direction = "TX" - channel = src + + host_port = str(payload.get("host_port") or "") + if self._target_channel: + channel = self._target_channel + else: + channel = host_port or src ts = float(payload.get("ts") or time.time()) self._queue.put((direction, data, ts, channel)) diff --git a/infra/comm/proxy_forward_manager.py b/infra/comm/proxy_forward_manager.py index 342f231..8669d8e 100644 --- a/infra/comm/proxy_forward_manager.py +++ b/infra/comm/proxy_forward_manager.py @@ -97,12 +97,26 @@ def start(self) -> StartResult: self._threads = [ threading.Thread( target=self._relay_loop, - args=(self._host_ser, self._device_ser, self.host_port, self.device_port), + args=( + self._host_ser, + self._device_ser, + self.host_port, + self.device_port, + "host", + "device", + ), daemon=True, ), threading.Thread( target=self._relay_loop, - args=(self._device_ser, self._host_ser, self.device_port, self.host_port), + args=( + self._device_ser, + self._host_ser, + self.device_port, + self.host_port, + "device", + "host", + ), daemon=True, ), ] @@ -125,7 +139,15 @@ def stop(self) -> None: self._host_ser = None self._device_ser = None - def _relay_loop(self, src: serial.Serial, dst: serial.Serial, src_port: str, dst_port: str) -> None: + def _relay_loop( + self, + src: serial.Serial, + dst: serial.Serial, + src_port: str, + dst_port: str, + src_role: str, + dst_role: str, + ) -> None: while self._running.is_set(): try: waiting = src.in_waiting if src.is_open else 0 @@ -142,6 +164,10 @@ def _relay_loop(self, src: serial.Serial, dst: serial.Serial, src_port: str, dst "pair_id": self.pair_id, "src": src_port, "dst": dst_port, + "src_role": src_role, + "dst_role": dst_role, + "host_port": self.host_port, + "device_port": self.device_port, "data": data, "ts": time.time(), }, From dc2975f29fcff0c5645b493cd3b5746266ed1968 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 01:28:10 +0800 Subject: [PATCH 005/145] feat(ui): add proxy error visibility and retry flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - 代理卡片状态映射补齐 error 语义(图标、路由状态、文案) - 卡片新增错误提示区,显示后端 error 文本并提供默认排障提示 - 新增卡片级一键重试按钮,直接触发代理重启 - 更新 Where 计划并标记 4.1 完成 - 影响范围: - 代理异常可见性提升,用户可在卡片内完成首次恢复动作 - 兼容性/行为变化: - 不改变代理创建/编辑/删除行为,仅增强异常态交互 - 依赖/环境: - 无新增依赖 - 验证: - npm run build 通过 [English] - Changes: - Completed error-state mapping in proxy cards (icon/route/status labels). - Added an inline error hint area with backend error text and fallback guidance. - Added a one-click retry action on proxy error cards. - Updated Where plan and marked item 4.1 as done. - Impact: - Proxy failures are now visible and recoverable directly from the card UI. - Compatibility/Behavior changes: - No change to create/edit/delete flow; only error-state UX enhancements. - Dependencies/Environment: - No new dependencies. - Verification: - Frontend build passed via npm run build. Refs: - .where-agent-progress.md --- .where-agent-progress.md | 29 ++++++----- .../src/components/ProxyMonitorView.vue | 30 +++++++++-- ui/frontend/src/style.css | 52 +++++++++++++++++++ 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 887d026..ec5247c 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -27,24 +27,29 @@ - [x] 2.3.1 转发数据发布 `proxy.data` - [x] 2.3.2 抓包引擎订阅 `proxy.data` 并产出 `capture.frame` -## 3. 已推进(第 3 轮) +## 3. 已完成(第 3 轮) - [x] 3.1 代理抓包方向与通道语义校准(RX/TX 与目标通道映射) - [x] 3.1.1 `proxy.data` 增加 `src_role/dst_role`(host/device) - [x] 3.1.2 抓包方向优先按角色判定,避免无目标通道时方向漂移 - [x] 3.1.3 抓包通道在未指定目标时优先归一到 `host_port` -## 4. P0 下一阶段(待做) -- [ ] 4.1 代理异常可视化增强(卡片级错误详情与重试引导) -- [ ] 4.2 代理状态持久化策略优化(运行中崩溃恢复/冷启动一致性) -- [ ] 4.3 真实设备回归:双串口回环、异常拔插、长时稳定性 +## 4. 已完成(第 4 轮) +- [x] 4.1 代理异常可视化增强(卡片级错误详情与重试引导) + - [x] 4.1.1 卡片状态映射支持 `error`(图标/路由状态/文案) + - [x] 4.1.2 卡片内展示后端错误文本(无错误时显示通用引导) + - [x] 4.1.3 新增“一键重试”按钮,直接触发重新启动代理 -## 5. P0 其余项(转发闭环后) -- [ ] 5.1 串口链路闭环(连接/收发/断开/异常) -- [ ] 5.2 协议解析闭环(正常帧与异常帧提示) -- [ ] 5.3 DSL 运行闭环(启动/停止/异常回传) -- [ ] 5.4 配置持久化闭环(重启一致、损坏回退) -- [ ] 5.5 打包运行一致性闭环(资源/版本/日志) +## 5. P0 下一阶段(待做) +- [ ] 5.1 代理状态持久化策略优化(运行中崩溃恢复/冷启动一致性) +- [ ] 5.2 真实设备回归:双串口回环、异常拔插、长时稳定性 -## 6. 交付物 +## 6. P0 其余项(转发闭环后) +- [ ] 6.1 串口链路闭环(连接/收发/断开/异常) +- [ ] 6.2 协议解析闭环(正常帧与异常帧提示) +- [ ] 6.3 DSL 运行闭环(启动/停止/异常回传) +- [ ] 6.4 配置持久化闭环(重启一致、损坏回退) +- [ ] 6.5 打包运行一致性闭环(资源/版本/日志) + +## 7. 交付物 - [ ] 输出《功能验收清单(可逐项打勾)》 - [ ] 输出《P0 回归用例(可复现步骤 + 预期)》 diff --git a/ui/frontend/src/components/ProxyMonitorView.vue b/ui/frontend/src/components/ProxyMonitorView.vue index 5442bc7..fc275d9 100644 --- a/ui/frontend/src/components/ProxyMonitorView.vue +++ b/ui/frontend/src/components/ProxyMonitorView.vue @@ -83,12 +83,14 @@ function withBridgeResult(result, onSuccess) { function mapProxyFromBackend(payload) { const status = payload.status || 'stopped' const active = status === 'running' - const statusLabel = active ? tr('运行中') : tr('已停止') - const routeLabel = active ? tr('转发中') : tr('离线') - const routeTone = active ? 'primary' : 'muted' - const statusIcon = active ? 'swap_horizontal_circle' : 'pause_circle' - const routeIcon = active ? 'keyboard_double_arrow_right' : 'more_horiz' + const isError = status === 'error' + const statusLabel = active ? tr('运行中') : isError ? tr('异常') : tr('已停止') + const routeLabel = active ? tr('转发中') : isError ? tr('连接失败') : tr('离线') + const routeTone = active ? 'primary' : isError ? 'danger' : 'muted' + const statusIcon = active ? 'swap_horizontal_circle' : isError ? 'error' : 'pause_circle' + const routeIcon = active ? 'keyboard_double_arrow_right' : isError ? 'error' : 'more_horiz' const baud = payload.baud ? String(payload.baud) : '115200' + const error = typeof payload.error === 'string' ? payload.error.trim() : '' proxySeq = Math.max(proxySeq, Number(String(payload.id || '').replace(/\D/g, '')) || proxySeq) const parityMap = { 无: 'none', @@ -119,6 +121,7 @@ function mapProxyFromBackend(payload) { bandwidth: payload.bandwidth || '0.0', bandwidthUnit: payload.bandwidthUnit || 'KB/s', spark: payload.spark || '', + error, active, toggleLabel: statusLabel, } @@ -381,6 +384,7 @@ function setProxyStatus(proxy, active) { proxy.routeTone = routeTone proxy.statusIcon = statusIcon proxy.routeIcon = routeIcon + proxy.error = '' proxy.active = active if (bridge && bridge.value && bridge.value.set_proxy_pair_status) { @@ -392,6 +396,11 @@ function setProxyStatus(proxy, active) { } } +function retryProxy(proxy) { + if (!proxy) return + setProxyStatus(proxy, true) +} + function saveProxy() { const payload = { name: proxyName.value || tr('未命名转发对'), @@ -638,6 +647,17 @@ onBeforeUnmount(() => {
+
+
+ warning + {{ proxy.error || tr('代理启动失败,请检查端口占用和参数配置。') }} +
+ +
+
-
-
-
-
- {{ proxy.statusIcon }} -
-
-

{{ tr(proxy.name) }}

-

{{ proxy.meta }}

-
-
- - - {{ proxyStatusLabel(proxy.status) }} - -
- -
-
-

{{ tr('主机源端口') }}

- {{ proxy.hostPort }} -
-
- {{ proxy.routeIcon }} - {{ proxyRouteLabel(proxy.status) }} -
-
-

{{ tr('设备代理端口') }}

- {{ proxy.devicePort }} -
-
- -
-
-

{{ tr('波特率') }}

- {{ proxy.baud }} -
-
-
-

{{ tr('实时带宽') }}

- - {{ proxy.bandwidth }} - {{ proxy.bandwidthUnit }} - -
-
- - - -
-
-
-
- -
-
- warning - {{ proxy.error || tr('代理启动失败,请检查端口占用和参数配置。') }} -
- -
- - -
+ :proxy="proxy" + :status-label="proxyStatusLabel(proxy.status)" + :route-label="proxyRouteLabel(proxy.status)" + :toggle-label="proxyToggleLabel(proxy.status)" + @capture="openCaptureModal" + @edit="openEditModal" + @delete="confirmDeleteProxy" + @retry="retryProxy" + @toggle="setProxyStatus($event.proxy, $event.active)" + />
diff --git a/ui/frontend/src/components/proxy/ProxyPanelCard.vue b/ui/frontend/src/components/proxy/ProxyPanelCard.vue new file mode 100644 index 0000000..978ed62 --- /dev/null +++ b/ui/frontend/src/components/proxy/ProxyPanelCard.vue @@ -0,0 +1,114 @@ + + + From f32b1084d918e5e5dbfe6237ce354779095fc9ce Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 16:51:50 +0800 Subject: [PATCH 034/145] refactor(frontend): extract settings panels component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract settings panels component - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract settings panels component - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 2 +- ui/frontend/src/App.vue | 123 ++----------- ui/frontend/src/components/SettingsPanels.vue | 166 ++++++++++++++++++ 3 files changed, 182 insertions(+), 109 deletions(-) create mode 100644 ui/frontend/src/components/SettingsPanels.vue diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 5fd1c46..be23849 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -6,6 +6,6 @@ - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 - [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,待继续拆分视图逻辑 - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 -- [~] H. Settings 逻辑下沉:已接管 settingsDirty/快照提交/回滚,待拆分设置视图区块 +- [~] H. Settings 逻辑下沉:已接管 settingsDirty/快照提交/回滚,并拆分 SettingsPanels 视图区块,待继续下沉细分交互逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 - [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown 交互回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 67c80e1..7ef9b83 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -9,6 +9,7 @@ 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 SettingsPanels from './components/SettingsPanels.vue' import { yaml as yamlLanguage } from '@codemirror/lang-yaml' import LayoutRenderer from './ui/LayoutRenderer.vue' import { useUiRuntimeStore } from './stores/uiRuntime' @@ -150,10 +151,6 @@ const protocolDraft = ref({ 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('') @@ -2245,110 +2242,20 @@ function unlockSidebarWidth() { -
- - - - -
-
-
-
- tune{{ t('settings.tab.general') }} -
-
- - -
-
-
- {{ t('settings.autoConnect.title') }} -

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

-
- -
-
-
-
- extension{{ t('settings.tab.plugins') }} -
- -
-
- {{ t('settings.plugins.title') }} - -
-
-
-
-
Modbus TCP/RTU
-
{{ tr('v1.2.4 - 已启用') }}
-
- {{ tr('已启用') }} -
-
-
-
{{ tr('MQTT 适配器') }}
-
{{ tr('v0.9.8 - 未安装') }}
-
- {{ tr('未安装') }} -
-
-
-
- {{ t('settings.autoConnect.title') }} -

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

-
- -
-
- -
-
- tune{{ t('settings.tab.runtime') }} -
-
- {{ tr('暂无可配置项,运行时设置将随着模块扩展开放。') }} -
-
- -
-
- folder_open{{ t('settings.tab.logs') }} -
-
- {{ tr('日志采集与归档策略将在后续版本中提供。') }} -
-
-
+ -
- - - - -
-
-
-
-
-
{{ card.name }}
-
{{ card.desc || tr('暂无描述') }}
-
- {{ card.statusText }} -
-
-
- {{ row.label }} - {{ row.value }} -
-
-
- - -
-
-
-
- inventory_2 -
-

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

-

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

- -
-
+
diff --git a/ui/frontend/src/components/ProtocolCardsSection.test.ts b/ui/frontend/src/components/ProtocolCardsSection.test.ts new file mode 100644 index 0000000..396da1b --- /dev/null +++ b/ui/frontend/src/components/ProtocolCardsSection.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import ProtocolCardsSection from './ProtocolCardsSection.vue' + +function mountProtocolCardsSection(withCards = true) { + const host = document.createElement('div') + document.body.appendChild(host) + const events: string[] = [] + const cards = withCards + ? [ + { + id: 'c1', + name: 'Modbus RTU', + desc: '', + statusClass: 'badge-green', + statusText: '已启用', + source: 'custom', + rows: [{ label: '版本', value: '1.0.0' }], + }, + ] + : [] + + const Root = defineComponent({ + components: { ProtocolCardsSection }, + data() { + return { tab: 'all' } + }, + render() { + return h(ProtocolCardsSection, { + protocolTab: this.tab, + filteredProtocolCards: cards, + onSetTab: (nextTab: string) => { + events.push(`tab:${nextTab}`) + this.tab = nextTab + }, + onCreate: () => events.push('create'), + onDetails: () => events.push('details'), + onDelete: () => events.push('delete'), + }) + }, + }) + + const app = createApp(Root) + app.provide('t', (key: string) => key) + app.provide('tr', (text: string) => text) + app.mount(host) + + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('ProtocolCardsSection interactions', () => { + it('emits tab and item actions', async () => { + const vm = mountProtocolCardsSection(true) + const tabButton = Array.from(vm.host.querySelectorAll('.tab-strip button')).find((item) => + item.textContent?.includes('Modbus') + ) as HTMLButtonElement + tabButton?.click() + await tick() + + const ghostButton = vm.host.querySelector('.protocol-actions .btn.btn-ghost') as HTMLButtonElement + const deleteButton = vm.host.querySelector('.protocol-actions .icon-btn') as HTMLButtonElement + ghostButton?.click() + deleteButton?.click() + await tick() + + expect(vm.events).toContain('tab:modbus') + expect(vm.events).toContain('details') + expect(vm.events).toContain('delete') + vm.unmount() + }) + + it('shows empty state and emits create', async () => { + const vm = mountProtocolCardsSection(false) + const createButton = vm.host.querySelector('.protocol-card.empty .btn.btn-primary') as HTMLButtonElement + createButton?.click() + await tick() + expect(vm.events).toContain('create') + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/ProtocolCardsSection.vue b/ui/frontend/src/components/ProtocolCardsSection.vue new file mode 100644 index 0000000..8e34a57 --- /dev/null +++ b/ui/frontend/src/components/ProtocolCardsSection.vue @@ -0,0 +1,66 @@ + + + From 379fe98357a4e9a50e640ddb5f2f915c96e9119b Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:03:57 +0800 Subject: [PATCH 040/145] refactor(frontend): extract protocol header component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract protocol header component - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract protocol header component - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 18 +------ .../src/components/ProtocolHeader.test.ts | 54 +++++++++++++++++++ ui/frontend/src/components/ProtocolHeader.vue | 26 +++++++++ 4 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 ui/frontend/src/components/ProtocolHeader.test.ts create mode 100644 ui/frontend/src/components/ProtocolHeader.vue diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 8902bb5..e959365 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页 ProtocolCardsSection,待继续拆分其余大视图区块 +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页 ProtocolHeader/ProtocolCardsSection,待继续拆分其余大视图区块 - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolCardsSection 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 13139a0..d049e33 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -12,6 +12,7 @@ import DropdownSelect from './components/DropdownSelect.vue' import SettingsPanels from './components/SettingsPanels.vue' import SettingsHeader from './components/SettingsHeader.vue' import ProtocolCardsSection from './components/ProtocolCardsSection.vue' +import ProtocolHeader from './components/ProtocolHeader.vue' import { yaml as yamlLanguage } from '@codemirror/lang-yaml' import LayoutRenderer from './ui/LayoutRenderer.vue' import { useUiRuntimeStore } from './stores/uiRuntime' @@ -2057,22 +2058,7 @@ function unlockSidebarWidth() { />
- + events.push('refresh'), + onCreate: () => events.push('create'), + }) + }, + }) + + const app = createApp(Root) + app.provide('t', (key: string) => key) + app.mount(host) + + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('ProtocolHeader interactions', () => { + it('emits refresh and create events', async () => { + const vm = mountProtocolHeader() + const buttons = vm.host.querySelectorAll('button') + ;(buttons[0] as HTMLButtonElement)?.click() + ;(buttons[1] as HTMLButtonElement)?.click() + await tick() + + expect(vm.events).toEqual(['refresh', 'create']) + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/ProtocolHeader.vue b/ui/frontend/src/components/ProtocolHeader.vue new file mode 100644 index 0000000..a3d1847 --- /dev/null +++ b/ui/frontend/src/components/ProtocolHeader.vue @@ -0,0 +1,26 @@ + + + From 54234a5356d085134302db47f74afc9778713708 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:07:53 +0800 Subject: [PATCH 041/145] refactor(frontend): extract protocol edit and delete modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract protocol edit and delete modals - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract protocol edit and delete modals - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 102 +++++------------- .../components/ProtocolDeleteModal.test.ts | 55 ++++++++++ .../src/components/ProtocolDeleteModal.vue | 38 +++++++ .../src/components/ProtocolEditModal.test.ts | 78 ++++++++++++++ .../src/components/ProtocolEditModal.vue | 94 ++++++++++++++++ 6 files changed, 292 insertions(+), 79 deletions(-) create mode 100644 ui/frontend/src/components/ProtocolDeleteModal.test.ts create mode 100644 ui/frontend/src/components/ProtocolDeleteModal.vue create mode 100644 ui/frontend/src/components/ProtocolEditModal.test.ts create mode 100644 ui/frontend/src/components/ProtocolEditModal.vue diff --git a/.where-agent-progress.md b/.where-agent-progress.md index e959365..4f400b3 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页 ProtocolHeader/ProtocolCardsSection,待继续拆分其余大视图区块 +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页 Header/Cards 及协议编辑/删除弹窗区块,待继续拆分其余大视图区块 - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection/ProtocolModal 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index d049e33..12e14c3 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -13,6 +13,8 @@ import SettingsPanels from './components/SettingsPanels.vue' import SettingsHeader from './components/SettingsHeader.vue' import ProtocolCardsSection from './components/ProtocolCardsSection.vue' import ProtocolHeader from './components/ProtocolHeader.vue' +import ProtocolEditModal from './components/ProtocolEditModal.vue' +import ProtocolDeleteModal from './components/ProtocolDeleteModal.vue' import { yaml as yamlLanguage } from '@codemirror/lang-yaml' import LayoutRenderer from './ui/LayoutRenderer.vue' import { useUiRuntimeStore } from './stores/uiRuntime' @@ -1111,6 +1113,14 @@ function closeProtocolDialog() { protocolDialogOpen.value = false } +function updateProtocolDraft({ field, value }) { + if (!field) return + protocolDraft.value = { + ...protocolDraft.value, + [field]: value, + } +} + function saveProtocol() { if (!bridge.value) { protocolDialogOpen.value = false @@ -2220,84 +2230,22 @@ function unlockSidebarWidth() { - + - + diff --git a/ui/frontend/src/components/ProtocolDeleteModal.test.ts b/ui/frontend/src/components/ProtocolDeleteModal.test.ts new file mode 100644 index 0000000..47102b1 --- /dev/null +++ b/ui/frontend/src/components/ProtocolDeleteModal.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import ProtocolDeleteModal from './ProtocolDeleteModal.vue' + +function mountProtocolDeleteModal() { + const host = document.createElement('div') + document.body.appendChild(host) + const events: string[] = [] + + const Root = defineComponent({ + components: { ProtocolDeleteModal }, + render() { + return h(ProtocolDeleteModal, { + open: true, + deleting: { name: 'ProtoA' }, + onClose: () => events.push('close'), + onConfirm: () => events.push('confirm'), + }) + }, + }) + + const app = createApp(Root) + app.provide('tr', (text: string) => text) + app.mount(host) + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('ProtocolDeleteModal interactions', () => { + it('emits confirm', async () => { + const vm = mountProtocolDeleteModal() + const confirmButton = Array.from(vm.host.querySelectorAll('.modal-footer button')).find((item) => + item.textContent?.includes('确认删除') + ) as HTMLButtonElement + confirmButton?.click() + await tick() + expect(vm.events).toContain('confirm') + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/ProtocolDeleteModal.vue b/ui/frontend/src/components/ProtocolDeleteModal.vue new file mode 100644 index 0000000..1d27d47 --- /dev/null +++ b/ui/frontend/src/components/ProtocolDeleteModal.vue @@ -0,0 +1,38 @@ + + + diff --git a/ui/frontend/src/components/ProtocolEditModal.test.ts b/ui/frontend/src/components/ProtocolEditModal.test.ts new file mode 100644 index 0000000..3966241 --- /dev/null +++ b/ui/frontend/src/components/ProtocolEditModal.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import ProtocolEditModal from './ProtocolEditModal.vue' + +function mountProtocolEditModal() { + const host = document.createElement('div') + document.body.appendChild(host) + const events: string[] = [] + + const Root = defineComponent({ + components: { ProtocolEditModal }, + data() { + return { + draft: { + name: 'A', + key: 'a', + category: 'custom', + status: 'custom', + desc: 'desc', + }, + } + }, + render() { + return h(ProtocolEditModal, { + open: true, + mode: 'edit', + draft: this.draft, + editing: { driver: 'DriverX' }, + onClose: () => events.push('close'), + onSave: () => events.push('save'), + onUpdateDraft: ({ field, value }: { field: string; value: string }) => { + events.push(`update:${field}:${value}`) + this.draft = { ...this.draft, [field]: value } + }, + }) + }, + }) + + const app = createApp(Root) + app.provide('t', (key: string) => key) + app.provide('tr', (text: string) => text) + app.mount(host) + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('ProtocolEditModal interactions', () => { + it('emits field updates and save', async () => { + const vm = mountProtocolEditModal() + const nameInput = vm.host.querySelector('input[type="text"]') as HTMLInputElement + nameInput.value = 'NewName' + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + const saveButton = Array.from(vm.host.querySelectorAll('.modal-footer button')).find((item) => + item.textContent?.includes('保存') + ) as HTMLButtonElement + saveButton?.click() + await tick() + + expect(vm.events.find((item) => item.startsWith('update:name:'))).toBeTruthy() + expect(vm.events).toContain('save') + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/ProtocolEditModal.vue b/ui/frontend/src/components/ProtocolEditModal.vue new file mode 100644 index 0000000..1128522 --- /dev/null +++ b/ui/frontend/src/components/ProtocolEditModal.vue @@ -0,0 +1,94 @@ + + + From 3e0f00fcf89937b272eb52d2d49710c5a314c749 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:12:58 +0800 Subject: [PATCH 042/145] refactor(frontend): extract channel dialog modal component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract channel dialog modal component - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract channel dialog modal component - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 162 ++++------------ .../src/components/ChannelDialogModal.test.ts | 86 +++++++++ .../src/components/ChannelDialogModal.vue | 179 ++++++++++++++++++ 4 files changed, 303 insertions(+), 128 deletions(-) create mode 100644 ui/frontend/src/components/ChannelDialogModal.test.ts create mode 100644 ui/frontend/src/components/ChannelDialogModal.vue diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 4f400b3..e1d4a1c 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页 Header/Cards 及协议编辑/删除弹窗区块,待继续拆分其余大视图区块 +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页区块与 ChannelDialog 弹窗区块,待继续拆分其余大视图区块 - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection/ProtocolModal 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 12e14c3..d175ce4 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -9,6 +9,7 @@ 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 ChannelDialogModal from './components/ChannelDialogModal.vue' import SettingsPanels from './components/SettingsPanels.vue' import SettingsHeader from './components/SettingsHeader.vue' import ProtocolCardsSection from './components/ProtocolCardsSection.vue' @@ -2102,132 +2103,41 @@ function unlockSidebarWidth() { />
- + events.push('close'), + onSubmit: () => events.push('submit'), + 'onUpdate:channelName': (value: string) => events.push(`name:${value}`), + 'onUpdate:channelAutoConnect': (value: boolean) => events.push(`auto:${value}`), + }) + }, + }) + + const app = createApp(Root) + app.provide('tr', (text: string) => text) + app.mount(host) + + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('ChannelDialogModal interactions', () => { + it('emits close/submit and field updates', async () => { + const vm = mountChannelDialogModal() + + const textInput = vm.host.querySelector('input[type="text"]') as HTMLInputElement + textInput.value = 'CH-B' + textInput.dispatchEvent(new Event('input', { bubbles: true })) + + const checkbox = vm.host.querySelector('input[type="checkbox"]') as HTMLInputElement + checkbox.checked = true + checkbox.dispatchEvent(new Event('change', { bubbles: true })) + + const footerButtons = vm.host.querySelectorAll('.modal-footer button') + ;(footerButtons[0] as HTMLButtonElement)?.click() + ;(footerButtons[1] as HTMLButtonElement)?.click() + await tick() + + expect(vm.events).toContain('name:CH-B') + expect(vm.events).toContain('auto:true') + expect(vm.events).toContain('close') + expect(vm.events).toContain('submit') + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/ChannelDialogModal.vue b/ui/frontend/src/components/ChannelDialogModal.vue new file mode 100644 index 0000000..072902f --- /dev/null +++ b/ui/frontend/src/components/ChannelDialogModal.vue @@ -0,0 +1,179 @@ + + + From e3f666f5a8b23d9761435c93f16469ed7ed29167 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:16:30 +0800 Subject: [PATCH 043/145] refactor(frontend): extract ui yaml preview modal component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract ui yaml preview modal component - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract ui yaml preview modal component - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 30 +-------- .../src/components/UiYamlPreviewModal.test.ts | 67 +++++++++++++++++++ .../src/components/UiYamlPreviewModal.vue | 49 ++++++++++++++ 4 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 ui/frontend/src/components/UiYamlPreviewModal.test.ts create mode 100644 ui/frontend/src/components/UiYamlPreviewModal.vue diff --git a/.where-agent-progress.md b/.where-agent-progress.md index e1d4a1c..f8f2f36 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页区块与 ChannelDialog 弹窗区块,待继续拆分其余大视图区块 +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页/ChannelDialog/UIYamlPreview 弹窗区块,待继续拆分其余大视图区块 - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index d175ce4..a01e247 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -16,8 +16,8 @@ import ProtocolCardsSection from './components/ProtocolCardsSection.vue' import ProtocolHeader from './components/ProtocolHeader.vue' import ProtocolEditModal from './components/ProtocolEditModal.vue' import ProtocolDeleteModal from './components/ProtocolDeleteModal.vue' +import UiYamlPreviewModal from './components/UiYamlPreviewModal.vue' import { yaml as yamlLanguage } from '@codemirror/lang-yaml' -import LayoutRenderer from './ui/LayoutRenderer.vue' import { useUiRuntimeStore } from './stores/uiRuntime' import * as i18nCore from './i18n' import { fallbackPorts, networkDefaults, serialDefaults, supportedBaudRates, uiDefaults } from './config/runtimeDefaults' @@ -2159,33 +2159,7 @@ function unlockSidebarWidth() { - + diff --git a/ui/frontend/src/components/UiYamlPreviewModal.test.ts b/ui/frontend/src/components/UiYamlPreviewModal.test.ts new file mode 100644 index 0000000..8443fb8 --- /dev/null +++ b/ui/frontend/src/components/UiYamlPreviewModal.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import UiYamlPreviewModal from './UiYamlPreviewModal.vue' + +function mountUiYamlPreviewModal(runtime: any) { + const host = document.createElement('div') + document.body.appendChild(host) + const events: string[] = [] + + const Root = defineComponent({ + components: { UiYamlPreviewModal }, + render() { + return h(UiYamlPreviewModal, { + open: true, + runtime, + onClose: () => events.push('close'), + }) + }, + }) + + const app = createApp(Root) + app.provide('tr', (text: string) => text) + app.mount(host) + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('UiYamlPreviewModal interactions', () => { + it('shows parse error and emits close', async () => { + const vm = mountUiYamlPreviewModal({ + parseError: { message: 'bad yaml', path: 'root.a', line: 10, column: 2 }, + lastGoodConfig: null, + widgetsById: {}, + }) + expect(vm.host.textContent).toContain('bad yaml') + const closeButton = vm.host.querySelector('.icon-btn') as HTMLButtonElement + closeButton?.click() + await tick() + expect(vm.events).toContain('close') + vm.unmount() + }) + + it('shows empty state when no parse error and no layout', () => { + const vm = mountUiYamlPreviewModal({ + parseError: null, + lastGoodConfig: null, + widgetsById: {}, + }) + expect(vm.host.textContent).toContain('暂无可渲染的 UI 配置') + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/UiYamlPreviewModal.vue b/ui/frontend/src/components/UiYamlPreviewModal.vue new file mode 100644 index 0000000..a16b0b5 --- /dev/null +++ b/ui/frontend/src/components/UiYamlPreviewModal.vue @@ -0,0 +1,49 @@ + + + From 34d726e376ac94c4754096a8236c5b6f8396e30f Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:26:23 +0800 Subject: [PATCH 044/145] refactor(frontend): extract protocol manager composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract protocol manager composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract protocol manager composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 219 ++--------------- .../composables/useProtocolManager.test.ts | 80 +++++++ .../src/composables/useProtocolManager.ts | 223 ++++++++++++++++++ 4 files changed, 330 insertions(+), 196 deletions(-) create mode 100644 ui/frontend/src/composables/useProtocolManager.test.ts create mode 100644 ui/frontend/src/composables/useProtocolManager.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index f8f2f36..2990070 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,并拆分协议页/ChannelDialog/UIYamlPreview 弹窗区块,待继续拆分其余大视图区块 +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理逻辑到 useProtocolManager - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index a01e247..4caaafc 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -27,6 +27,7 @@ import { useSerialInteraction } from './composables/useSerialInteraction' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' +import { useProtocolManager } from './composables/useProtocolManager' const bridge = ref(null) const sidebarRef = ref(null) @@ -145,20 +146,6 @@ const uiLabels = computed(() => ({ 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 uiRuntime = useUiRuntimeStore() const uiModalOpen = ref(false) const appVersion = ref('') @@ -219,8 +206,6 @@ const channelCards = computed(() => { }) }) -const protocolCards = ref([]) - const isConnected = computed(() => connectionInfo.value.state === 'connected') const sliceTail = (items, limit) => { @@ -289,9 +274,30 @@ const filteredChannelCards = computed(() => { if (channelTab.value === 'all') return channelCards.value return channelCards.value.filter((card) => card.category === channelTab.value) }) -const filteredProtocolCards = computed(() => { - if (protocolTab.value === 'all') return protocolCards.value - return protocolCards.value.filter((card) => card.category === protocolTab.value) + +const { + protocolTab, + protocolDialogOpen, + protocolDialogMode, + protocolEditing, + protocolDeleteOpen, + protocolDeleting, + protocolDraft, + filteredProtocolCards, + refreshProtocols, + openCreateProtocol, + openProtocolDetails, + closeProtocolDialog, + updateProtocolDraft, + saveProtocol, + openProtocolDelete, + closeProtocolDelete, + confirmProtocolDelete, + setProtocolTab, +} = useProtocolManager({ + bridge, + tr, + withResult, }) function setSettingsTab(tab) { @@ -1007,177 +1013,6 @@ function scheduleChannelRefresh() { }, 150) } -function protocolCategory(key) { - const name = String(key || "").toLowerCase() - if (name.startsWith("modbus_")) return "modbus" - if (name.includes("tcp")) return "tcp" - return "custom" -} - -function prettyProtocolName(key, fallback) { - const value = String(key || "").trim() - 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 - if (upper.length <= 2) return upper - return part.charAt(0).toUpperCase() + part.slice(1) - }) - 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 || "") - const name = String(item.name || "") - const category = String(item.category || protocolCategory(key)) - const status = String(item.status || "available") - 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), - key, - name: name || prettyProtocolName(key, driver), - driver, - category, - desc, - statusText: statusInfo.text, - statusClass: statusInfo.className, - status, - source, - rows: [ - { label: tr('键名'), value: key || '--' }, - { label: tr('驱动'), value: driver || '--' }, - { label: tr('分类'), value: category || '--' }, - ], - } - }) -} - -function refreshProtocols() { - if (!bridge.value || !bridge.value.list_protocols) return - withResult(bridge.value.list_protocols(), (items) => { - setProtocols(items) - }) -} - -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 updateProtocolDraft({ field, value }) { - if (!field) return - protocolDraft.value = { - ...protocolDraft.value, - [field]: value, - } -} - -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() refreshPorts() @@ -1803,10 +1638,6 @@ function setChannelTab(tab) { channelTab.value = tab } -function setProtocolTab(tab) { - protocolTab.value = tab -} - const { buildSettingsPayload, normalizeSettings, applySettings } = useSettingsPersistence({ refs: { uiLanguage, diff --git a/ui/frontend/src/composables/useProtocolManager.test.ts b/ui/frontend/src/composables/useProtocolManager.test.ts new file mode 100644 index 0000000..79e91f3 --- /dev/null +++ b/ui/frontend/src/composables/useProtocolManager.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { useProtocolManager } from './useProtocolManager' + +function withResult(value: any, handler: (payload: any) => void) { + handler(value) +} + +describe('useProtocolManager', () => { + it('maps and filters protocol cards', () => { + const bridge = ref({}) + const mgr = useProtocolManager({ + bridge, + tr: (text) => text, + withResult, + }) + + mgr.setProtocols([ + { key: 'modbus_rtu', driver: 'DrvA', status: 'available', source: 'builtin' }, + { key: 'custom_x', driver: 'DrvB', status: 'custom', source: 'custom', category: 'custom' }, + ]) + expect(mgr.filteredProtocolCards.value.length).toBe(2) + mgr.setProtocolTab('custom') + expect(mgr.filteredProtocolCards.value.length).toBe(1) + }) + + it('opens create/edit dialogs and updates draft', () => { + const mgr = useProtocolManager({ + bridge: ref({}), + tr: (text) => text, + withResult, + }) + mgr.openCreateProtocol() + expect(mgr.protocolDialogOpen.value).toBe(true) + expect(mgr.protocolDialogMode.value).toBe('create') + + mgr.openProtocolDetails({ id: 'p1', name: 'A', key: 'a', source: 'custom', status: 'custom', category: 'custom' }) + expect(mgr.protocolDialogMode.value).toBe('edit') + mgr.updateProtocolDraft({ field: 'name', value: 'B' }) + expect(mgr.protocolDraft.value.name).toBe('B') + }) + + it('save and delete call bridge paths', () => { + let created = 0 + let updated = 0 + let deleted = 0 + const bridge = ref({ + list_protocols: () => [], + create_protocol: () => { + created += 1 + return true + }, + update_protocol: () => { + updated += 1 + return true + }, + delete_protocol: () => { + deleted += 1 + return true + }, + }) + const mgr = useProtocolManager({ + bridge, + tr: (text) => text, + withResult, + }) + + mgr.openCreateProtocol() + mgr.saveProtocol() + expect(created).toBe(1) + + mgr.openProtocolDetails({ id: 'p1', name: 'A', key: 'a', source: 'custom', status: 'custom', category: 'custom' }) + mgr.saveProtocol() + expect(updated).toBe(1) + + mgr.openProtocolDelete({ id: 'p1', source: 'custom' }) + mgr.confirmProtocolDelete() + expect(deleted).toBe(1) + }) +}) diff --git a/ui/frontend/src/composables/useProtocolManager.ts b/ui/frontend/src/composables/useProtocolManager.ts new file mode 100644 index 0000000..03d5847 --- /dev/null +++ b/ui/frontend/src/composables/useProtocolManager.ts @@ -0,0 +1,223 @@ +import { computed, ref, type Ref } from 'vue' + +type WithResultFn = (value: any, handler: (payload: any) => void) => void + +type UseProtocolManagerOptions = { + bridge: Ref + tr: (text: string) => string + withResult: WithResultFn +} + +function protocolCategory(key: string) { + const name = String(key || '').toLowerCase() + if (name.startsWith('modbus_')) return 'modbus' + if (name.includes('tcp')) return 'tcp' + return 'custom' +} + +function prettyProtocolName(key: string, fallback: string, tr: (text: string) => string) { + const value = String(key || '').trim() + 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 + if (upper.length <= 2) return upper + return part.charAt(0).toUpperCase() + part.slice(1) + }) + return parts.join(' ') +} + +function protocolStatusInfo(status: string, tr: (text: string) => string) { + 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' } +} + +export function useProtocolManager(options: UseProtocolManagerOptions) { + 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 protocolCards = ref([]) + + const filteredProtocolCards = computed(() => { + if (protocolTab.value === 'all') return protocolCards.value + return protocolCards.value.filter((card) => card.category === protocolTab.value) + }) + + function setProtocols(items: any[]) { + const list = Array.isArray(items) ? items : [] + protocolCards.value = list.map((item) => { + const key = String(item.key || item.id || '') + const driver = String(item.driver || '') + const name = String(item.name || '') + const category = String(item.category || protocolCategory(key)) + const status = String(item.status || 'available') + const source = String(item.source || 'builtin') + const desc = String(item.desc || '') + const statusInfo = protocolStatusInfo(status, options.tr) + return { + id: key || driver || Math.random().toString(36).slice(2), + key, + name: name || prettyProtocolName(key, driver, options.tr), + driver, + category, + desc, + statusText: statusInfo.text, + statusClass: statusInfo.className, + status, + source, + rows: [ + { label: options.tr('键名'), value: key || '--' }, + { label: options.tr('驱动'), value: driver || '--' }, + { label: options.tr('分类'), value: category || '--' }, + ], + } + }) + } + + function refreshProtocols() { + if (!options.bridge.value || !options.bridge.value.list_protocols) return + options.withResult(options.bridge.value.list_protocols(), (items) => { + setProtocols(items) + }) + } + + 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: any) { + 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 updateProtocolDraft({ field, value }: { field: string; value: any }) { + if (!field) return + protocolDraft.value = { + ...protocolDraft.value, + [field]: value, + } + } + + function saveProtocol() { + if (!options.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 (!options.bridge.value.create_protocol) return + options.withResult(options.bridge.value.create_protocol(payload), () => { + refreshProtocols() + protocolDialogOpen.value = false + }) + return + } + if (protocolDialogMode.value === 'edit') { + if (!options.bridge.value.update_protocol) return + options.withResult(options.bridge.value.update_protocol(payload), () => { + refreshProtocols() + protocolDialogOpen.value = false + }) + return + } + protocolDialogOpen.value = false + } + + function openProtocolDelete(card: any) { + if (!card || card.source !== 'custom') return + protocolDeleting.value = card + protocolDeleteOpen.value = true + } + + function closeProtocolDelete() { + protocolDeleteOpen.value = false + protocolDeleting.value = null + } + + function confirmProtocolDelete() { + if (!options.bridge.value || !options.bridge.value.delete_protocol || !protocolDeleting.value) { + closeProtocolDelete() + return + } + const id = protocolDeleting.value.id + options.withResult(options.bridge.value.delete_protocol(id), () => { + refreshProtocols() + closeProtocolDelete() + }) + } + + function setProtocolTab(tab: string) { + protocolTab.value = tab + } + + return { + protocolTab, + protocolDialogOpen, + protocolDialogMode, + protocolEditing, + protocolDeleteOpen, + protocolDeleting, + protocolDraft, + protocolCards, + filteredProtocolCards, + setProtocols, + refreshProtocols, + openCreateProtocol, + openProtocolDetails, + closeProtocolDialog, + updateProtocolDraft, + saveProtocol, + openProtocolDelete, + closeProtocolDelete, + confirmProtocolDelete, + setProtocolTab, + } +} From 2ceadbeadbd2f83ef11dd22f0b714c121456064a Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:29:52 +0800 Subject: [PATCH 045/145] refactor(frontend): extract channel dialog composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract channel dialog composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract channel dialog composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 82 +++++++-------- .../src/composables/useChannelDialog.test.ts | 97 ++++++++++++++++++ .../src/composables/useChannelDialog.ts | 99 +++++++++++++++++++ 4 files changed, 234 insertions(+), 48 deletions(-) create mode 100644 ui/frontend/src/composables/useChannelDialog.test.ts create mode 100644 ui/frontend/src/composables/useChannelDialog.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 2990070..97ff626 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理逻辑到 useProtocolManager +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理与 ChannelDialog 逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 4caaafc..55eb29c 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -24,6 +24,7 @@ import { fallbackPorts, networkDefaults, serialDefaults, supportedBaudRates, uiD import { normalizeSerialPortName } from './utils/serialPort' import { useChannelState } from './composables/useChannelState' import { useSerialInteraction } from './composables/useSerialInteraction' +import { useChannelDialog } from './composables/useChannelDialog' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -300,6 +301,41 @@ const { withResult, }) +const { handleNewChannel, openChannelSettings, closeChannelDialog, submitChannelDialog } = useChannelDialog({ + refs: { + channelDialogOpen, + channelDialogMode, + channelType, + channelName, + channelPort, + channelBaud, + channelDataBits, + channelParity, + channelStopBits, + channelFlowControl, + channelReadTimeout, + channelWriteTimeout, + channelHost, + channelTcpPort, + channelAutoConnect, + selectedPort, + ports, + defaultBaud, + defaultParity, + defaultStopBits, + tcpHost, + tcpPort, + autoConnectOnStart, + }, + defaults: { + fallbackPorts, + serialDefaults: { baud: serialDefaults.baud }, + networkDefaults: { host: networkDefaults.host }, + }, + bridge, + normalizeSerialPortName, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1018,52 +1054,6 @@ function handleChannelRefresh() { refreshPorts() } -function handleNewChannel() { - channelDialogMode.value = 'create' - channelType.value = 'serial' - channelName.value = '' - channelPort.value = normalizeSerialPortName(selectedPort.value || ports.value[0] || fallbackPorts[0]) - channelBaud.value = Number(defaultBaud.value || serialDefaults.baud) - channelDataBits.value = '8' - channelParity.value = defaultParity.value || 'none' - channelStopBits.value = defaultStopBits.value || '1' - channelFlowControl.value = 'none' - channelReadTimeout.value = 1000 - channelWriteTimeout.value = 1000 - channelHost.value = tcpHost.value || networkDefaults.host - channelTcpPort.value = Number(tcpPort.value || 502) - channelAutoConnect.value = !!autoConnectOnStart.value - channelDialogOpen.value = true -} - -function openChannelSettings() { - handleNewChannel() - channelDialogMode.value = 'serial' - channelType.value = 'serial' -} - -function closeChannelDialog() { - channelDialogOpen.value = false -} - -function submitChannelDialog() { - if (!bridge.value) return - if (channelType.value === 'serial') { - if (channelAutoConnect.value) { - const targetPort = normalizeSerialPortName(channelPort.value) - if (targetPort) { - channelPort.value = targetPort - bridge.value.connect_serial(targetPort, Number(channelBaud.value || 115200)) - } - } - } else if (channelType.value === 'tcp') { - if (channelAutoConnect.value) { - bridge.value.connect_tcp(channelHost.value, Number(channelTcpPort.value || 502)) - } - } - channelDialogOpen.value = false -} - function connectSerial() { if (!bridge.value) return if (isConnecting.value || isConnected.value) return diff --git a/ui/frontend/src/composables/useChannelDialog.test.ts b/ui/frontend/src/composables/useChannelDialog.test.ts new file mode 100644 index 0000000..e9fea7a --- /dev/null +++ b/ui/frontend/src/composables/useChannelDialog.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { useChannelDialog } from './useChannelDialog' + +function createRefs() { + return { + channelDialogOpen: ref(false), + channelDialogMode: ref('create'), + channelType: ref('serial'), + channelName: ref(''), + channelPort: ref(''), + channelBaud: ref(115200), + channelDataBits: ref('8'), + channelParity: ref('none'), + channelStopBits: ref('1'), + channelFlowControl: ref('none'), + channelReadTimeout: ref(0), + channelWriteTimeout: ref(0), + channelHost: ref(''), + channelTcpPort: ref(0), + channelAutoConnect: ref(false), + selectedPort: ref('COM9'), + ports: ref(['COM3', 'COM4']), + defaultBaud: ref(57600), + defaultParity: ref('even'), + defaultStopBits: ref('2'), + tcpHost: ref('192.168.1.2'), + tcpPort: ref(7001), + autoConnectOnStart: ref(true), + } +} + +describe('useChannelDialog', () => { + it('prepares new channel defaults', () => { + const refs = createRefs() + const mgr = useChannelDialog({ + refs, + defaults: { fallbackPorts: ['COM1'], serialDefaults: { baud: 9600 }, networkDefaults: { host: '127.0.0.1' } }, + bridge: ref(null), + normalizeSerialPortName: (value) => String(value || '').toUpperCase(), + }) + + mgr.handleNewChannel() + expect(refs.channelDialogOpen.value).toBe(true) + expect(refs.channelPort.value).toBe('COM9') + expect(refs.channelBaud.value).toBe(57600) + expect(refs.channelParity.value).toBe('even') + }) + + it('opens serial settings mode', () => { + const refs = createRefs() + const mgr = useChannelDialog({ + refs, + defaults: { fallbackPorts: ['COM1'], serialDefaults: { baud: 9600 }, networkDefaults: { host: '127.0.0.1' } }, + bridge: ref(null), + normalizeSerialPortName: (value) => String(value || ''), + }) + + mgr.openChannelSettings() + expect(refs.channelDialogMode.value).toBe('serial') + expect(refs.channelType.value).toBe('serial') + }) + + it('submits serial and tcp connections through bridge', () => { + const refs = createRefs() + let serialCalls = 0 + let tcpCalls = 0 + const mgr = useChannelDialog({ + refs, + defaults: { fallbackPorts: ['COM1'], serialDefaults: { baud: 9600 }, networkDefaults: { host: '127.0.0.1' } }, + bridge: ref({ + connect_serial: () => { + serialCalls += 1 + }, + connect_tcp: () => { + tcpCalls += 1 + }, + }), + normalizeSerialPortName: (value) => String(value || '').toUpperCase(), + }) + + refs.channelDialogOpen.value = true + refs.channelType.value = 'serial' + refs.channelPort.value = 'com5' + refs.channelAutoConnect.value = true + mgr.submitChannelDialog() + expect(serialCalls).toBe(1) + expect(refs.channelPort.value).toBe('COM5') + expect(refs.channelDialogOpen.value).toBe(false) + + refs.channelDialogOpen.value = true + refs.channelType.value = 'tcp' + refs.channelAutoConnect.value = true + mgr.submitChannelDialog() + expect(tcpCalls).toBe(1) + }) +}) diff --git a/ui/frontend/src/composables/useChannelDialog.ts b/ui/frontend/src/composables/useChannelDialog.ts new file mode 100644 index 0000000..07f1719 --- /dev/null +++ b/ui/frontend/src/composables/useChannelDialog.ts @@ -0,0 +1,99 @@ +import type { Ref } from 'vue' + +type ChannelDialogRefs = { + channelDialogOpen: Ref + channelDialogMode: Ref + channelType: Ref + channelName: Ref + channelPort: Ref + channelBaud: Ref + channelDataBits: Ref + channelParity: Ref + channelStopBits: Ref + channelFlowControl: Ref + channelReadTimeout: Ref + channelWriteTimeout: Ref + channelHost: Ref + channelTcpPort: Ref + channelAutoConnect: Ref + selectedPort: Ref + ports: Ref + defaultBaud: Ref + defaultParity: Ref + defaultStopBits: Ref + tcpHost: Ref + tcpPort: Ref + autoConnectOnStart: Ref +} + +type ChannelDialogDefaults = { + fallbackPorts: string[] + serialDefaults: { baud: number } + networkDefaults: { host: string } +} + +type UseChannelDialogOptions = { + refs: ChannelDialogRefs + defaults: ChannelDialogDefaults + bridge: Ref + normalizeSerialPortName: (value: any) => string +} + +export function useChannelDialog(options: UseChannelDialogOptions) { + const { refs } = options + + function handleNewChannel() { + refs.channelDialogMode.value = 'create' + refs.channelType.value = 'serial' + refs.channelName.value = '' + refs.channelPort.value = options.normalizeSerialPortName( + refs.selectedPort.value || refs.ports.value[0] || options.defaults.fallbackPorts[0] + ) + refs.channelBaud.value = Number(refs.defaultBaud.value || options.defaults.serialDefaults.baud) + refs.channelDataBits.value = '8' + refs.channelParity.value = refs.defaultParity.value || 'none' + refs.channelStopBits.value = refs.defaultStopBits.value || '1' + refs.channelFlowControl.value = 'none' + refs.channelReadTimeout.value = 1000 + refs.channelWriteTimeout.value = 1000 + refs.channelHost.value = refs.tcpHost.value || options.defaults.networkDefaults.host + refs.channelTcpPort.value = Number(refs.tcpPort.value || 502) + refs.channelAutoConnect.value = !!refs.autoConnectOnStart.value + refs.channelDialogOpen.value = true + } + + function openChannelSettings() { + handleNewChannel() + refs.channelDialogMode.value = 'serial' + refs.channelType.value = 'serial' + } + + function closeChannelDialog() { + refs.channelDialogOpen.value = false + } + + function submitChannelDialog() { + if (!options.bridge.value) return + if (refs.channelType.value === 'serial') { + if (refs.channelAutoConnect.value) { + const targetPort = options.normalizeSerialPortName(refs.channelPort.value) + if (targetPort) { + refs.channelPort.value = targetPort + options.bridge.value.connect_serial(targetPort, Number(refs.channelBaud.value || 115200)) + } + } + } else if (refs.channelType.value === 'tcp') { + if (refs.channelAutoConnect.value) { + options.bridge.value.connect_tcp(refs.channelHost.value, Number(refs.channelTcpPort.value || 502)) + } + } + refs.channelDialogOpen.value = false + } + + return { + handleNewChannel, + openChannelSettings, + closeChannelDialog, + submitChannelDialog, + } +} From 980495acf858fbde7f34df3e85375034b2a54532 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:50:22 +0800 Subject: [PATCH 046/145] refactor(frontend): extract payload sender composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract payload sender composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract payload sender composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 66 +++------------ .../src/composables/usePayloadSender.test.ts | 82 +++++++++++++++++++ .../src/composables/usePayloadSender.ts | 76 +++++++++++++++++ 4 files changed, 170 insertions(+), 58 deletions(-) create mode 100644 ui/frontend/src/composables/usePayloadSender.test.ts create mode 100644 ui/frontend/src/composables/usePayloadSender.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 97ff626..12bcf9f 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理与 ChannelDialog 逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 55eb29c..36bd5ac 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -25,6 +25,7 @@ import { normalizeSerialPortName } from './utils/serialPort' import { useChannelState } from './composables/useChannelState' import { useSerialInteraction } from './composables/useSerialInteraction' import { useChannelDialog } from './composables/useChannelDialog' +import { usePayloadSender } from './composables/usePayloadSender' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -336,6 +337,15 @@ const { handleNewChannel, openChannelSettings, closeChannelDialog, submitChannel normalizeSerialPortName, }) +const { sendPayload, sendQuickCommand } = usePayloadSender({ + bridge, + sendMode, + sendText, + sendHex, + appendCR, + appendLF, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1084,62 +1094,6 @@ function disconnect() { bridge.value.disconnect() } -function applyLineEndings(text, cr = appendCR.value, lf = appendLF.value) { - let payload = text - if (cr) payload += '\r' - if (lf) payload += '\n' - return payload -} - -function applyHexLineEndings(text, cr = appendCR.value, lf = appendLF.value) { - const parts = text.trim().split(/\\s+/).filter(Boolean) - if (cr) parts.push('0D') - if (lf) parts.push('0A') - return parts.join(' ') -} - -function sendAscii() { - if (!bridge.value || !sendText.value) return - const payload = applyLineEndings(sendText.value) - bridge.value.send_text(payload) -} - -function sendHexData() { - if (!bridge.value || !sendHex.value) return - const payload = applyHexLineEndings(sendHex.value) - bridge.value.send_hex(payload) -} - -function sendPayload() { - if (sendMode.value === 'hex') { - sendHexData() - } else { - sendAscii() - } -} - -function sendQuickCommand(cmd) { - if (!cmd) return - const payload = typeof cmd === 'string' ? cmd : cmd.payload || cmd.name || '' - if (!payload) return - const mode = typeof cmd === 'string' ? 'text' : cmd.mode || 'text' - const cr = typeof cmd === 'string' ? appendCR.value : cmd.appendCR ?? appendCR.value - const lf = typeof cmd === 'string' ? appendLF.value : cmd.appendLF ?? appendLF.value - if (mode === 'hex') { - sendMode.value = 'hex' - sendHex.value = payload - if (!bridge.value) return - const data = applyHexLineEndings(payload, cr, lf) - bridge.value.send_hex(data) - return - } - sendMode.value = 'text' - sendText.value = payload - if (!bridge.value) return - const data = applyLineEndings(payload, cr, lf) - bridge.value.send_text(data) -} - async function openUiYamlModal() { if (!uiModalOpen.value) { uiModalOpen.value = true diff --git a/ui/frontend/src/composables/usePayloadSender.test.ts b/ui/frontend/src/composables/usePayloadSender.test.ts new file mode 100644 index 0000000..359eaf8 --- /dev/null +++ b/ui/frontend/src/composables/usePayloadSender.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { applyHexLineEndings, applyLineEndings, usePayloadSender } from './usePayloadSender' + +describe('usePayloadSender', () => { + it('applies line endings helpers', () => { + expect(applyLineEndings('AT', true, true)).toBe('AT\r\n') + expect(applyHexLineEndings('AA BB', true, false)).toBe('AA BB 0D') + }) + + it('sends payload by mode', () => { + let sentText = '' + let sentHex = '' + const sender = usePayloadSender({ + bridge: ref({ + send_text: (value: string) => { + sentText = value + }, + send_hex: (value: string) => { + sentHex = value + }, + }), + sendMode: ref('text'), + sendText: ref('PING'), + sendHex: ref('AA BB'), + appendCR: ref(true), + appendLF: ref(false), + }) + sender.sendPayload() + expect(sentText).toBe('PING\r') + + const senderHex = usePayloadSender({ + bridge: ref({ + send_text: () => {}, + send_hex: (value: string) => { + sentHex = value + }, + }), + sendMode: ref('hex'), + sendText: ref(''), + sendHex: ref('AA BB'), + appendCR: ref(false), + appendLF: ref(true), + }) + senderHex.sendPayload() + expect(sentHex).toBe('AA BB 0A') + }) + + it('sends quick command and updates mode buffers', () => { + let sentText = '' + let sentHex = '' + const sendMode = ref('text') + const sendText = ref('') + const sendHex = ref('') + + const sender = usePayloadSender({ + bridge: ref({ + send_text: (value: string) => { + sentText = value + }, + send_hex: (value: string) => { + sentHex = value + }, + }), + sendMode, + sendText, + sendHex, + appendCR: ref(true), + appendLF: ref(true), + }) + + sender.sendQuickCommand({ payload: 'AT+GMR', mode: 'text', appendCR: false, appendLF: true }) + expect(sendMode.value).toBe('text') + expect(sendText.value).toBe('AT+GMR') + expect(sentText).toBe('AT+GMR\n') + + sender.sendQuickCommand({ payload: 'AA BB', mode: 'hex', appendCR: true, appendLF: false }) + expect(sendMode.value).toBe('hex') + expect(sendHex.value).toBe('AA BB') + expect(sentHex).toBe('AA BB 0D') + }) +}) diff --git a/ui/frontend/src/composables/usePayloadSender.ts b/ui/frontend/src/composables/usePayloadSender.ts new file mode 100644 index 0000000..65446f9 --- /dev/null +++ b/ui/frontend/src/composables/usePayloadSender.ts @@ -0,0 +1,76 @@ +import type { Ref } from 'vue' + +type UsePayloadSenderOptions = { + bridge: Ref + sendMode: Ref + sendText: Ref + sendHex: Ref + appendCR: Ref + appendLF: Ref +} + +export function applyLineEndings(text: string, cr = false, lf = false) { + let payload = text + if (cr) payload += '\r' + if (lf) payload += '\n' + return payload +} + +export function applyHexLineEndings(text: string, cr = false, lf = false) { + const parts = String(text || '') + .trim() + .split(/\s+/) + .filter(Boolean) + if (cr) parts.push('0D') + if (lf) parts.push('0A') + return parts.join(' ') +} + +export function usePayloadSender(options: UsePayloadSenderOptions) { + function sendAscii() { + if (!options.bridge.value || !options.sendText.value) return + const payload = applyLineEndings(options.sendText.value, options.appendCR.value, options.appendLF.value) + options.bridge.value.send_text(payload) + } + + function sendHexData() { + if (!options.bridge.value || !options.sendHex.value) return + const payload = applyHexLineEndings(options.sendHex.value, options.appendCR.value, options.appendLF.value) + options.bridge.value.send_hex(payload) + } + + function sendPayload() { + if (options.sendMode.value === 'hex') { + sendHexData() + return + } + sendAscii() + } + + function sendQuickCommand(cmd: any) { + if (!cmd) return + const payload = typeof cmd === 'string' ? cmd : cmd.payload || cmd.name || '' + if (!payload) return + const mode = typeof cmd === 'string' ? 'text' : cmd.mode || 'text' + const cr = typeof cmd === 'string' ? options.appendCR.value : cmd.appendCR ?? options.appendCR.value + const lf = typeof cmd === 'string' ? options.appendLF.value : cmd.appendLF ?? options.appendLF.value + if (mode === 'hex') { + options.sendMode.value = 'hex' + options.sendHex.value = payload + if (!options.bridge.value) return + const data = applyHexLineEndings(payload, cr, lf) + options.bridge.value.send_hex(data) + return + } + options.sendMode.value = 'text' + options.sendText.value = payload + if (!options.bridge.value) return + const data = applyLineEndings(payload, cr, lf) + options.bridge.value.send_text(data) + } + + return { + sendPayload, + sendQuickCommand, + } +} From 43896d1af2a413be0a75a0d42c2332183241986d Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:53:04 +0800 Subject: [PATCH 047/145] refactor(frontend): extract yaml document ops composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract yaml document ops composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract yaml document ops composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 96 ++-------------- .../composables/useYamlDocumentOps.test.ts | 77 +++++++++++++ .../src/composables/useYamlDocumentOps.ts | 105 ++++++++++++++++++ 4 files changed, 196 insertions(+), 86 deletions(-) create mode 100644 ui/frontend/src/composables/useYamlDocumentOps.test.ts create mode 100644 ui/frontend/src/composables/useYamlDocumentOps.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 12bcf9f..37e6211 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 36bd5ac..f2a106d 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -26,6 +26,7 @@ import { useChannelState } from './composables/useChannelState' import { useSerialInteraction } from './composables/useSerialInteraction' import { useChannelDialog } from './composables/useChannelDialog' import { usePayloadSender } from './composables/usePayloadSender' +import { useYamlDocumentOps } from './composables/useYamlDocumentOps' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -346,6 +347,17 @@ const { sendPayload, sendQuickCommand } = usePayloadSender({ appendLF, }) +const { loadYaml, saveYaml, handleYamlFile, copyYaml } = useYamlDocumentOps({ + bridge, + withResult, + yamlText, + scriptFileName, + scriptFilePath, + yamlFileInputRef, + refreshScriptVariables, + addScriptLog, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1129,90 +1141,6 @@ function stopScript() { addScriptLog('[INFO] Stop requested.') } -function loadYaml() { - if (bridge.value && bridge.value.load_yaml) { - withResult(bridge.value.load_yaml(), (payload) => { - if (!payload || !payload.text) return - yamlText.value = payload.text - scriptFileName.value = payload.name || scriptFileName.value - scriptFilePath.value = payload.path || scriptFilePath.value - refreshScriptVariables() - addScriptLog(`[INFO] Loaded: ${scriptFileName.value}`) - }) - return - } - if (!yamlFileInputRef.value) return - yamlFileInputRef.value.value = '' - yamlFileInputRef.value.click() -} - -function saveYaml() { - const payload = yamlText.value.trim() - if (!payload) { - addScriptLog('[WARN] YAML is empty, not saved.') - return - } - if (bridge.value && bridge.value.save_yaml) { - withResult(bridge.value.save_yaml(payload, scriptFileName.value || 'workflow.yaml'), (info) => { - if (!info) return - if (info.name) scriptFileName.value = info.name - if (info.path) scriptFilePath.value = info.path - addScriptLog(`[INFO] Saved: ${scriptFileName.value}`) - }) - return - } - const name = scriptFileName.value || 'script.yaml' - const blob = new Blob([payload], { type: 'text/yaml' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = name - document.body.appendChild(link) - link.click() - link.remove() - URL.revokeObjectURL(url) - addScriptLog(`[INFO] Saved: ${name}`) -} - -function handleYamlFile(event) { - const file = event && event.target && event.target.files ? event.target.files[0] : null - if (!file) return - const reader = new FileReader() - reader.onload = () => { - const text = typeof reader.result === 'string' ? reader.result : '' - yamlText.value = text - scriptFileName.value = file.name - scriptFilePath.value = file.name - refreshScriptVariables() - addScriptLog(`[INFO] Loaded: ${file.name}`) - } - reader.readAsText(file) -} - -async function copyYaml() { - const payload = yamlText.value.trim() - if (!payload) { - addScriptLog('[WARN] YAML is empty, nothing to copy.') - return - } - if (navigator.clipboard && navigator.clipboard.writeText) { - try { - await navigator.clipboard.writeText(payload) - addScriptLog('[INFO] YAML copied to clipboard.') - return - } catch (err) { - addScriptLog('[WARN] Clipboard API failed, falling back.') - } - } - const temp = document.createElement('textarea') - temp.value = payload - document.body.appendChild(temp) - temp.select() - document.execCommand('copy') - temp.remove() - addScriptLog('[INFO] YAML copied to clipboard.') -} - function searchYaml() { const keyword = window.prompt(tr('搜索关键词')) if (!keyword) return diff --git a/ui/frontend/src/composables/useYamlDocumentOps.test.ts b/ui/frontend/src/composables/useYamlDocumentOps.test.ts new file mode 100644 index 0000000..17eb97f --- /dev/null +++ b/ui/frontend/src/composables/useYamlDocumentOps.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useYamlDocumentOps } from './useYamlDocumentOps' + +function withResult(value: any, handler: (payload: any) => void) { + handler(value) +} + +describe('useYamlDocumentOps', () => { + it('loads yaml from bridge payload', () => { + const logs: string[] = [] + const yamlText = ref('') + const scriptFileName = ref('old.yaml') + const scriptFilePath = ref('') + const refreshScriptVariables = vi.fn() + + const ops = useYamlDocumentOps({ + bridge: ref({ + load_yaml: () => ({ text: 'a: 1', name: 'new.yaml', path: '/tmp/new.yaml' }), + }), + withResult, + yamlText, + scriptFileName, + scriptFilePath, + yamlFileInputRef: ref(null), + refreshScriptVariables, + addScriptLog: (line) => logs.push(line), + }) + + ops.loadYaml() + expect(yamlText.value).toBe('a: 1') + expect(scriptFileName.value).toBe('new.yaml') + expect(refreshScriptVariables).toHaveBeenCalled() + expect(logs.some((line) => line.includes('Loaded: new.yaml'))).toBe(true) + }) + + it('saves yaml through bridge and updates file info', () => { + const logs: string[] = [] + const yamlText = ref('a: 2') + const scriptFileName = ref('current.yaml') + const scriptFilePath = ref('') + + const ops = useYamlDocumentOps({ + bridge: ref({ + save_yaml: () => ({ name: 'saved.yaml', path: '/tmp/saved.yaml' }), + }), + withResult, + yamlText, + scriptFileName, + scriptFilePath, + yamlFileInputRef: ref(null), + refreshScriptVariables: () => {}, + addScriptLog: (line) => logs.push(line), + }) + + ops.saveYaml() + expect(scriptFileName.value).toBe('saved.yaml') + expect(logs.some((line) => line.includes('Saved: saved.yaml'))).toBe(true) + }) + + it('warns when copying empty yaml', async () => { + const logs: string[] = [] + const ops = useYamlDocumentOps({ + bridge: ref(null), + withResult, + yamlText: ref(''), + scriptFileName: ref('x.yaml'), + scriptFilePath: ref(''), + yamlFileInputRef: ref(null), + refreshScriptVariables: () => {}, + addScriptLog: (line) => logs.push(line), + }) + + await ops.copyYaml() + expect(logs).toContain('[WARN] YAML is empty, nothing to copy.') + }) +}) diff --git a/ui/frontend/src/composables/useYamlDocumentOps.ts b/ui/frontend/src/composables/useYamlDocumentOps.ts new file mode 100644 index 0000000..fc06c7e --- /dev/null +++ b/ui/frontend/src/composables/useYamlDocumentOps.ts @@ -0,0 +1,105 @@ +import type { Ref } from 'vue' + +type UseYamlDocumentOpsOptions = { + bridge: Ref + withResult: (value: any, handler: (payload: any) => void) => void + yamlText: Ref + scriptFileName: Ref + scriptFilePath: Ref + yamlFileInputRef: Ref + refreshScriptVariables: () => void + addScriptLog: (line: string) => void +} + +export function useYamlDocumentOps(options: UseYamlDocumentOpsOptions) { + function loadYaml() { + if (options.bridge.value && options.bridge.value.load_yaml) { + options.withResult(options.bridge.value.load_yaml(), (payload) => { + if (!payload || !payload.text) return + options.yamlText.value = payload.text + options.scriptFileName.value = payload.name || options.scriptFileName.value + options.scriptFilePath.value = payload.path || options.scriptFilePath.value + options.refreshScriptVariables() + options.addScriptLog(`[INFO] Loaded: ${options.scriptFileName.value}`) + }) + return + } + if (!options.yamlFileInputRef.value) return + options.yamlFileInputRef.value.value = '' + options.yamlFileInputRef.value.click() + } + + function saveYaml() { + const payload = options.yamlText.value.trim() + if (!payload) { + options.addScriptLog('[WARN] YAML is empty, not saved.') + return + } + if (options.bridge.value && options.bridge.value.save_yaml) { + options.withResult(options.bridge.value.save_yaml(payload, options.scriptFileName.value || 'workflow.yaml'), (info) => { + if (!info) return + if (info.name) options.scriptFileName.value = info.name + if (info.path) options.scriptFilePath.value = info.path + options.addScriptLog(`[INFO] Saved: ${options.scriptFileName.value}`) + }) + return + } + const name = options.scriptFileName.value || 'script.yaml' + const blob = new Blob([payload], { type: 'text/yaml' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = name + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + options.addScriptLog(`[INFO] Saved: ${name}`) + } + + function handleYamlFile(event: any) { + const file = event && event.target && event.target.files ? event.target.files[0] : null + if (!file) return + const reader = new FileReader() + reader.onload = () => { + const text = typeof reader.result === 'string' ? reader.result : '' + options.yamlText.value = text + options.scriptFileName.value = file.name + options.scriptFilePath.value = file.name + options.refreshScriptVariables() + options.addScriptLog(`[INFO] Loaded: ${file.name}`) + } + reader.readAsText(file) + } + + async function copyYaml() { + const payload = options.yamlText.value.trim() + if (!payload) { + options.addScriptLog('[WARN] YAML is empty, nothing to copy.') + return + } + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(payload) + options.addScriptLog('[INFO] YAML copied to clipboard.') + return + } catch { + options.addScriptLog('[WARN] Clipboard API failed, falling back.') + } + } + const temp = document.createElement('textarea') + temp.value = payload + document.body.appendChild(temp) + temp.select() + document.execCommand('copy') + temp.remove() + options.addScriptLog('[INFO] YAML copied to clipboard.') + } + + return { + loadYaml, + saveYaml, + handleYamlFile, + copyYaml, + } +} From 156d1b690388303802dc0e157daae65bf83a63ad Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 17:55:02 +0800 Subject: [PATCH 048/145] refactor(frontend): extract script runner composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract script runner composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract script runner composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 49 +++-------- .../src/composables/useScriptRunner.test.ts | 85 +++++++++++++++++++ .../src/composables/useScriptRunner.ts | 58 +++++++++++++ 4 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 ui/frontend/src/composables/useScriptRunner.test.ts create mode 100644 ui/frontend/src/composables/useScriptRunner.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 37e6211..1b80dc1 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index f2a106d..566a6f0 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -27,6 +27,7 @@ import { useSerialInteraction } from './composables/useSerialInteraction' import { useChannelDialog } from './composables/useChannelDialog' import { usePayloadSender } from './composables/usePayloadSender' import { useYamlDocumentOps } from './composables/useYamlDocumentOps' +import { useScriptRunner } from './composables/useScriptRunner' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -358,6 +359,19 @@ const { loadYaml, saveYaml, handleYamlFile, copyYaml } = useYamlDocumentOps({ addScriptLog, }) +const { openUiYamlModal, closeUiYamlModal, runScript, stopScript } = useScriptRunner({ + bridge, + uiRuntime, + uiModalOpen, + yamlText, + scriptRunning, + scriptState, + scriptStartMs, + scriptElapsedMs, + scriptProgress, + addScriptLog, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1106,41 +1120,6 @@ function disconnect() { bridge.value.disconnect() } -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() - if (!payload) { - addScriptLog('[WARN] YAML is empty, abort run.') - return - } - scriptRunning.value = true - scriptState.value = 'starting' - scriptStartMs.value = Date.now() - scriptElapsedMs.value = 0 - scriptProgress.value = 0 - openUiYamlModal() - bridge.value.run_script(payload) -} - -function stopScript() { - if (!bridge.value) return - scriptState.value = 'stopping' - bridge.value.stop_script() - addScriptLog('[INFO] Stop requested.') -} - function searchYaml() { const keyword = window.prompt(tr('搜索关键词')) if (!keyword) return diff --git a/ui/frontend/src/composables/useScriptRunner.test.ts b/ui/frontend/src/composables/useScriptRunner.test.ts new file mode 100644 index 0000000..d56d4ee --- /dev/null +++ b/ui/frontend/src/composables/useScriptRunner.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { useScriptRunner } from './useScriptRunner' + +describe('useScriptRunner', () => { + it('opens and closes ui yaml modal', async () => { + const uiRuntime = { + yamlText: '', + _parseWithBridge: async () => {}, + } + const runner = useScriptRunner({ + bridge: ref({ run_script: () => {}, stop_script: () => {} }), + uiRuntime, + uiModalOpen: ref(false), + yamlText: ref('a: 1'), + scriptRunning: ref(false), + scriptState: ref('idle'), + scriptStartMs: ref(0), + scriptElapsedMs: ref(0), + scriptProgress: ref(0), + addScriptLog: () => {}, + }) + + await runner.openUiYamlModal() + runner.closeUiYamlModal() + }) + + it('runs script when yaml is non-empty', () => { + let called = 0 + const scriptRunning = ref(false) + const scriptState = ref('idle') + const scriptProgress = ref(50) + const runner = useScriptRunner({ + bridge: ref({ + run_script: () => { + called += 1 + }, + stop_script: () => {}, + }), + uiRuntime: { yamlText: '', _parseWithBridge: async () => {} }, + uiModalOpen: ref(false), + yamlText: ref('name: test'), + scriptRunning, + scriptState, + scriptStartMs: ref(0), + scriptElapsedMs: ref(10), + scriptProgress, + addScriptLog: () => {}, + }) + + runner.runScript() + expect(called).toBe(1) + expect(scriptRunning.value).toBe(true) + expect(scriptState.value).toBe('starting') + expect(scriptProgress.value).toBe(0) + }) + + it('stops script and logs', () => { + let stopped = 0 + const logs: string[] = [] + const scriptState = ref('running') + const runner = useScriptRunner({ + bridge: ref({ + run_script: () => {}, + stop_script: () => { + stopped += 1 + }, + }), + uiRuntime: { yamlText: '', _parseWithBridge: async () => {} }, + uiModalOpen: ref(false), + yamlText: ref('abc'), + scriptRunning: ref(true), + scriptState, + scriptStartMs: ref(0), + scriptElapsedMs: ref(0), + scriptProgress: ref(0), + addScriptLog: (line) => logs.push(line), + }) + + runner.stopScript() + expect(stopped).toBe(1) + expect(scriptState.value).toBe('stopping') + expect(logs).toContain('[INFO] Stop requested.') + }) +}) diff --git a/ui/frontend/src/composables/useScriptRunner.ts b/ui/frontend/src/composables/useScriptRunner.ts new file mode 100644 index 0000000..80910e3 --- /dev/null +++ b/ui/frontend/src/composables/useScriptRunner.ts @@ -0,0 +1,58 @@ +import type { Ref } from 'vue' + +type UseScriptRunnerOptions = { + bridge: Ref + uiRuntime: any + uiModalOpen: Ref + yamlText: Ref + scriptRunning: Ref + scriptState: Ref + scriptStartMs: Ref + scriptElapsedMs: Ref + scriptProgress: Ref + addScriptLog: (line: string) => void +} + +export function useScriptRunner(options: UseScriptRunnerOptions) { + async function openUiYamlModal() { + if (!options.uiModalOpen.value) { + options.uiModalOpen.value = true + } + options.uiRuntime.yamlText = options.yamlText.value + await options.uiRuntime._parseWithBridge() + } + + function closeUiYamlModal() { + options.uiModalOpen.value = false + } + + function runScript() { + if (!options.bridge.value) return + const payload = options.yamlText.value.trim() + if (!payload) { + options.addScriptLog('[WARN] YAML is empty, abort run.') + return + } + options.scriptRunning.value = true + options.scriptState.value = 'starting' + options.scriptStartMs.value = Date.now() + options.scriptElapsedMs.value = 0 + options.scriptProgress.value = 0 + openUiYamlModal() + options.bridge.value.run_script(payload) + } + + function stopScript() { + if (!options.bridge.value) return + options.scriptState.value = 'stopping' + options.bridge.value.stop_script() + options.addScriptLog('[INFO] Stop requested.') + } + + return { + openUiYamlModal, + closeUiYamlModal, + runScript, + stopScript, + } +} From fbf429823a23a1139044fd2d9cd997d4e58cd611 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 18:00:45 +0800 Subject: [PATCH 049/145] refactor(frontend): extract yaml search composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract yaml search composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract yaml search composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 30 ++------- .../src/composables/useYamlSearch.test.ts | 67 +++++++++++++++++++ ui/frontend/src/composables/useYamlSearch.ts | 35 ++++++++++ 4 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 ui/frontend/src/composables/useYamlSearch.test.ts create mode 100644 ui/frontend/src/composables/useYamlSearch.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 1b80dc1..f785270 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 566a6f0..468ba0b 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -28,6 +28,7 @@ import { useChannelDialog } from './composables/useChannelDialog' import { usePayloadSender } from './composables/usePayloadSender' import { useYamlDocumentOps } from './composables/useYamlDocumentOps' import { useScriptRunner } from './composables/useScriptRunner' +import { useYamlSearch } from './composables/useYamlSearch' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -372,6 +373,12 @@ const { openUiYamlModal, closeUiYamlModal, runScript, stopScript } = useScriptRu addScriptLog, }) +const { searchYaml } = useYamlSearch({ + tr, + addScriptLog, + getEditor: () => yamlEditor, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1120,29 +1127,6 @@ function disconnect() { bridge.value.disconnect() } -function searchYaml() { - const keyword = window.prompt(tr('搜索关键词')) - if (!keyword) return - if (yamlEditor) { - const doc = yamlEditor.state.doc.toString() - const lower = doc.toLowerCase() - const idx = lower.indexOf(keyword.toLowerCase()) - if (idx === -1) { - addScriptLog(`[INFO] Not found: ${keyword}`) - return - } - yamlEditor.dispatch({ - selection: { anchor: idx, head: idx + keyword.length }, - scrollIntoView: true, - }) - return - } - const found = window.find(keyword) - if (!found) { - addScriptLog(`[INFO] Not found: ${keyword}`) - } -} - function clearScriptLogs() { scriptLogs.value = [] scriptLogBuffer.length = 0 diff --git a/ui/frontend/src/composables/useYamlSearch.test.ts b/ui/frontend/src/composables/useYamlSearch.test.ts new file mode 100644 index 0000000..fe65df7 --- /dev/null +++ b/ui/frontend/src/composables/useYamlSearch.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useYamlSearch } from './useYamlSearch' + +const originalPrompt = window.prompt +const originalFind = window.find + +afterEach(() => { + window.prompt = originalPrompt + window.find = originalFind +}) + +describe('useYamlSearch', () => { + it('dispatches selection when keyword found in editor', () => { + const dispatch = vi.fn() + window.prompt = vi.fn(() => 'abc') + const search = useYamlSearch({ + tr: (text) => text, + addScriptLog: () => {}, + getEditor: () => ({ + state: { + doc: { + toString: () => 'xxx ABC yyy', + }, + }, + dispatch, + }), + }) + + search.searchYaml() + expect(dispatch).toHaveBeenCalled() + }) + + it('logs not found when editor exists but no match', () => { + const logs: string[] = [] + window.prompt = vi.fn(() => 'needle') + const search = useYamlSearch({ + tr: (text) => text, + addScriptLog: (line) => logs.push(line), + getEditor: () => ({ + state: { + doc: { + toString: () => 'haystack', + }, + }, + dispatch: () => {}, + }), + }) + + search.searchYaml() + expect(logs).toContain('[INFO] Not found: needle') + }) + + it('falls back to window.find when editor missing', () => { + const logs: string[] = [] + window.prompt = vi.fn(() => 'x') + window.find = vi.fn(() => false) + const search = useYamlSearch({ + tr: (text) => text, + addScriptLog: (line) => logs.push(line), + getEditor: () => null, + }) + + search.searchYaml() + expect(window.find).toHaveBeenCalledWith('x') + expect(logs).toContain('[INFO] Not found: x') + }) +}) diff --git a/ui/frontend/src/composables/useYamlSearch.ts b/ui/frontend/src/composables/useYamlSearch.ts new file mode 100644 index 0000000..4dbd985 --- /dev/null +++ b/ui/frontend/src/composables/useYamlSearch.ts @@ -0,0 +1,35 @@ +type UseYamlSearchOptions = { + tr: (text: string) => string + addScriptLog: (line: string) => void + getEditor: () => any +} + +export function useYamlSearch(options: UseYamlSearchOptions) { + function searchYaml() { + const keyword = window.prompt(options.tr('搜索关键词')) + if (!keyword) return + const yamlEditor = options.getEditor() + if (yamlEditor) { + const doc = yamlEditor.state.doc.toString() + const lower = doc.toLowerCase() + const idx = lower.indexOf(keyword.toLowerCase()) + if (idx === -1) { + options.addScriptLog(`[INFO] Not found: ${keyword}`) + return + } + yamlEditor.dispatch({ + selection: { anchor: idx, head: idx + keyword.length }, + scrollIntoView: true, + }) + return + } + const found = window.find(keyword) + if (!found) { + options.addScriptLog(`[INFO] Not found: ${keyword}`) + } + } + + return { + searchYaml, + } +} From 658d39314c2e3dc246d9f8adaa289b3175d1a89b Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 18:03:09 +0800 Subject: [PATCH 050/145] refactor(frontend): extract script log helpers composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract script log helpers composable - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract script log helpers composable - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 37 +++++------- .../composables/useScriptLogHelpers.test.ts | 59 +++++++++++++++++++ .../src/composables/useScriptLogHelpers.ts | 37 ++++++++++++ 4 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 ui/frontend/src/composables/useScriptLogHelpers.test.ts create mode 100644 ui/frontend/src/composables/useScriptLogHelpers.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index f785270..5da1a8a 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 468ba0b..bd0b1d6 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -28,6 +28,7 @@ import { useChannelDialog } from './composables/useChannelDialog' import { usePayloadSender } from './composables/usePayloadSender' import { useYamlDocumentOps } from './composables/useYamlDocumentOps' import { useScriptRunner } from './composables/useScriptRunner' +import { useScriptLogHelpers } from './composables/useScriptLogHelpers' import { useYamlSearch } from './composables/useYamlSearch' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' @@ -379,6 +380,21 @@ const { searchYaml } = useYamlSearch({ getEditor: () => yamlEditor, }) +const { clearScriptLogs, scrollScriptLogsToBottom, refreshScriptVariables } = useScriptLogHelpers({ + scriptLogs, + scriptLogBuffer, + scriptLogRef, + scriptVariablesList, + yamlText, + parseScriptVariables, + clearScriptVarTimer: () => { + if (scriptVarTimer) { + window.clearTimeout(scriptVarTimer) + scriptVarTimer = null + } + }, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1127,27 +1143,6 @@ function disconnect() { bridge.value.disconnect() } -function clearScriptLogs() { - scriptLogs.value = [] - scriptLogBuffer.length = 0 -} - -function scrollScriptLogsToBottom() { - if (!scriptLogRef.value) return - const root = scriptLogRef.value.rootEl - const el = root && root.value ? root.value : root - if (!el) return - el.scrollTop = el.scrollHeight -} - -function refreshScriptVariables() { - if (scriptVarTimer) { - window.clearTimeout(scriptVarTimer) - scriptVarTimer = null - } - scriptVariablesList.value = parseScriptVariables(yamlText.value) -} - function attachBridge(obj) { if (!obj || attachedBridge === obj) return attachedBridge = obj diff --git a/ui/frontend/src/composables/useScriptLogHelpers.test.ts b/ui/frontend/src/composables/useScriptLogHelpers.test.ts new file mode 100644 index 0000000..fd88ae6 --- /dev/null +++ b/ui/frontend/src/composables/useScriptLogHelpers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { useScriptLogHelpers } from './useScriptLogHelpers' + +describe('useScriptLogHelpers', () => { + it('clears logs and buffer', () => { + const scriptLogs = ref([{ text: 'a' }]) + const scriptLogBuffer = [{ text: 'b' }] + const helpers = useScriptLogHelpers({ + scriptLogs, + scriptLogBuffer, + scriptLogRef: ref(null), + scriptVariablesList: ref([]), + yamlText: ref(''), + parseScriptVariables: () => [], + clearScriptVarTimer: () => {}, + }) + + helpers.clearScriptLogs() + expect(scriptLogs.value).toEqual([]) + expect(scriptLogBuffer.length).toBe(0) + }) + + it('scrolls to bottom when log element exists', () => { + const el = { scrollTop: 0, scrollHeight: 200 } + const helpers = useScriptLogHelpers({ + scriptLogs: ref([]), + scriptLogBuffer: [], + scriptLogRef: ref({ rootEl: el }), + scriptVariablesList: ref([]), + yamlText: ref(''), + parseScriptVariables: () => [], + clearScriptVarTimer: () => {}, + }) + + helpers.scrollScriptLogsToBottom() + expect(el.scrollTop).toBe(200) + }) + + it('refreshes script variables with parser output', () => { + let cleared = 0 + const scriptVariablesList = ref([]) + const helpers = useScriptLogHelpers({ + scriptLogs: ref([]), + scriptLogBuffer: [], + scriptLogRef: ref(null), + scriptVariablesList, + yamlText: ref('a: 1\nb: 2'), + parseScriptVariables: () => [{ name: 'a' }], + clearScriptVarTimer: () => { + cleared += 1 + }, + }) + + helpers.refreshScriptVariables() + expect(cleared).toBe(1) + expect(scriptVariablesList.value).toEqual([{ name: 'a' }]) + }) +}) diff --git a/ui/frontend/src/composables/useScriptLogHelpers.ts b/ui/frontend/src/composables/useScriptLogHelpers.ts new file mode 100644 index 0000000..d1b95db --- /dev/null +++ b/ui/frontend/src/composables/useScriptLogHelpers.ts @@ -0,0 +1,37 @@ +import type { Ref } from 'vue' + +type UseScriptLogHelpersOptions = { + scriptLogs: Ref + scriptLogBuffer: any[] + scriptLogRef: Ref + scriptVariablesList: Ref + yamlText: Ref + parseScriptVariables: (text: string) => any[] + clearScriptVarTimer: () => void +} + +export function useScriptLogHelpers(options: UseScriptLogHelpersOptions) { + function clearScriptLogs() { + options.scriptLogs.value = [] + options.scriptLogBuffer.length = 0 + } + + function scrollScriptLogsToBottom() { + if (!options.scriptLogRef.value) return + const root = options.scriptLogRef.value.rootEl + const el = root && root.value ? root.value : root + if (!el) return + el.scrollTop = el.scrollHeight + } + + function refreshScriptVariables() { + options.clearScriptVarTimer() + options.scriptVariablesList.value = options.parseScriptVariables(options.yamlText.value) + } + + return { + clearScriptLogs, + scrollScriptLogsToBottom, + refreshScriptVariables, + } +} From 92c1fb56b2d6df163f1eb7eb472181dd333133eb Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 18:05:39 +0800 Subject: [PATCH 051/145] refactor(frontend): extract script bridge signal bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract script bridge signal bindings - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract script bridge signal bindings - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 46 +++--------- .../useScriptBridgeSignals.test.ts | 75 +++++++++++++++++++ .../src/composables/useScriptBridgeSignals.ts | 61 +++++++++++++++ 4 files changed, 148 insertions(+), 38 deletions(-) create mode 100644 ui/frontend/src/composables/useScriptBridgeSignals.test.ts create mode 100644 ui/frontend/src/composables/useScriptBridgeSignals.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 5da1a8a..746895c 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index bd0b1d6..b4af5ec 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -30,6 +30,7 @@ import { useYamlDocumentOps } from './composables/useYamlDocumentOps' import { useScriptRunner } from './composables/useScriptRunner' import { useScriptLogHelpers } from './composables/useScriptLogHelpers' import { useYamlSearch } from './composables/useYamlSearch' +import { useScriptBridgeSignals } from './composables/useScriptBridgeSignals' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -395,6 +396,14 @@ const { clearScriptLogs, scrollScriptLogsToBottom, refreshScriptVariables } = us }, }) +const { bindScriptBridgeSignals } = useScriptBridgeSignals({ + scriptRunning, + scriptState, + scriptProgress, + addScriptLog, + scheduleSetChannels, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1212,42 +1221,7 @@ function attachBridge(obj) { connectionInfo.value = nextInfo scheduleChannelRefresh() }) - obj.script_log.connect((line) => addScriptLog(line)) - obj.script_state.connect((state) => { - if (state === '__running__') { - scriptRunning.value = true - scriptState.value = 'running' - return - } - if (state === '__finished__') { - scriptRunning.value = false - scriptState.value = 'idle' - scriptProgress.value = 100 - return - } - if (state === '__stopped__') { - scriptRunning.value = false - scriptState.value = 'idle' - return - } - if (state === '__error__') { - scriptRunning.value = false - scriptState.value = 'error' - return - } - scriptState.value = state - if (state) { - scriptRunning.value = true - } - }) - obj.script_progress.connect((value) => { - scriptProgress.value = value - }) - if (obj.channel_update) { - obj.channel_update.connect((items) => { - scheduleSetChannels(items) - }) - } + bindScriptBridgeSignals(obj) refreshPorts() refreshChannels() refreshProtocols() diff --git a/ui/frontend/src/composables/useScriptBridgeSignals.test.ts b/ui/frontend/src/composables/useScriptBridgeSignals.test.ts new file mode 100644 index 0000000..f29f00d --- /dev/null +++ b/ui/frontend/src/composables/useScriptBridgeSignals.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { useScriptBridgeSignals } from './useScriptBridgeSignals' + +function createSignal() { + const listeners: Array<(payload: any) => void> = [] + return { + connect(fn: (payload: any) => void) { + listeners.push(fn) + }, + emit(payload: any) { + listeners.forEach((fn) => fn(payload)) + }, + } +} + +describe('useScriptBridgeSignals', () => { + it('binds script log and channel update handlers', () => { + const logs: any[] = [] + const channels: any[] = [] + const scriptLog = createSignal() + const channelUpdate = createSignal() + + const mgr = useScriptBridgeSignals({ + scriptRunning: ref(false), + scriptState: ref('idle'), + scriptProgress: ref(0), + addScriptLog: (line) => logs.push(line), + scheduleSetChannels: (items) => channels.push(items), + }) + + mgr.bindScriptBridgeSignals({ + script_log: scriptLog, + channel_update: channelUpdate, + }) + + scriptLog.emit('line-a') + channelUpdate.emit([1, 2, 3]) + expect(logs).toEqual(['line-a']) + expect(channels).toEqual([[1, 2, 3]]) + }) + + it('handles script state transitions and progress', () => { + const scriptRunning = ref(false) + const scriptState = ref('idle') + const scriptProgress = ref(0) + const scriptStateSignal = createSignal() + const scriptProgressSignal = createSignal() + + const mgr = useScriptBridgeSignals({ + scriptRunning, + scriptState, + scriptProgress, + addScriptLog: () => {}, + scheduleSetChannels: () => {}, + }) + + mgr.bindScriptBridgeSignals({ + script_state: scriptStateSignal, + script_progress: scriptProgressSignal, + }) + + scriptStateSignal.emit('__running__') + expect(scriptRunning.value).toBe(true) + expect(scriptState.value).toBe('running') + + scriptProgressSignal.emit(55) + expect(scriptProgress.value).toBe(55) + + scriptStateSignal.emit('__finished__') + expect(scriptRunning.value).toBe(false) + expect(scriptState.value).toBe('idle') + expect(scriptProgress.value).toBe(100) + }) +}) diff --git a/ui/frontend/src/composables/useScriptBridgeSignals.ts b/ui/frontend/src/composables/useScriptBridgeSignals.ts new file mode 100644 index 0000000..1111bf1 --- /dev/null +++ b/ui/frontend/src/composables/useScriptBridgeSignals.ts @@ -0,0 +1,61 @@ +import type { Ref } from 'vue' + +type UseScriptBridgeSignalsOptions = { + scriptRunning: Ref + scriptState: Ref + scriptProgress: Ref + addScriptLog: (line: any) => void + scheduleSetChannels: (items: any) => void +} + +export function useScriptBridgeSignals(options: UseScriptBridgeSignalsOptions) { + function bindScriptBridgeSignals(obj: any) { + if (!obj) return + if (obj.script_log && typeof obj.script_log.connect === 'function') { + obj.script_log.connect((line: any) => options.addScriptLog(line)) + } + if (obj.script_state && typeof obj.script_state.connect === 'function') { + obj.script_state.connect((state: string) => { + if (state === '__running__') { + options.scriptRunning.value = true + options.scriptState.value = 'running' + return + } + if (state === '__finished__') { + options.scriptRunning.value = false + options.scriptState.value = 'idle' + options.scriptProgress.value = 100 + return + } + if (state === '__stopped__') { + options.scriptRunning.value = false + options.scriptState.value = 'idle' + return + } + if (state === '__error__') { + options.scriptRunning.value = false + options.scriptState.value = 'error' + return + } + options.scriptState.value = state + if (state) { + options.scriptRunning.value = true + } + }) + } + if (obj.script_progress && typeof obj.script_progress.connect === 'function') { + obj.script_progress.connect((value: number) => { + options.scriptProgress.value = value + }) + } + if (obj.channel_update && typeof obj.channel_update.connect === 'function') { + obj.channel_update.connect((items: any) => { + options.scheduleSetChannels(items) + }) + } + } + + return { + bindScriptBridgeSignals, + } +} From c783ef376151d0000d42adb5903b0a5c3ed4b771 Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 18:09:38 +0800 Subject: [PATCH 052/145] refactor(frontend): extract communication bridge signal bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract communication bridge signal bindings - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract communication bridge signal bindings - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 76 ++++------------ .../composables/useCommBridgeSignals.test.ts | 91 +++++++++++++++++++ .../src/composables/useCommBridgeSignals.ts | 81 +++++++++++++++++ 4 files changed, 192 insertions(+), 60 deletions(-) create mode 100644 ui/frontend/src/composables/useCommBridgeSignals.test.ts create mode 100644 ui/frontend/src/composables/useCommBridgeSignals.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index 746895c..d038a45 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index b4af5ec..e01889c 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -31,6 +31,7 @@ import { useScriptRunner } from './composables/useScriptRunner' import { useScriptLogHelpers } from './composables/useScriptLogHelpers' import { useYamlSearch } from './composables/useYamlSearch' import { useScriptBridgeSignals } from './composables/useScriptBridgeSignals' +import { useCommBridgeSignals } from './composables/useCommBridgeSignals' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -404,6 +405,22 @@ const { bindScriptBridgeSignals } = useScriptBridgeSignals({ scheduleSetChannels, }) +const { bindCommBridgeSignals } = useCommBridgeSignals({ + parseBridgePayload, + addCommLog, + addCommBatch, + ingestCaptureFrame, + emitStatus, + scheduleChannelRefresh, + setConnectingFalse: () => { + isConnecting.value = false + }, + onConnectionInfo: (nextInfo) => { + connectionInfo.value = nextInfo + }, + shouldEmitDisconnected: (reason) => hasStatusActivity || Boolean(reason), +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -1163,64 +1180,7 @@ function attachBridge(obj) { } }) } - if (obj.comm_rx && obj.comm_tx) { - obj.comm_rx.connect((payload) => { - const parsed = parseBridgePayload(payload) - addCommLog('RX', parsed) - }) - obj.comm_tx.connect((payload) => { - const parsed = parseBridgePayload(payload) - addCommLog('TX', parsed) - }) - } else if (obj.comm_batch) { - obj.comm_batch.connect((batch) => addCommBatch(batch)) - } - if (obj.protocol_frame) { - obj.protocol_frame.connect((payload) => { - const ts = payload && payload.ts ? payload.ts : Date.now() / 1000 - 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 - isConnecting.value = false - if (!detail) { - const reason = payload && payload.reason ? String(payload.reason) : '' - const message = reason ? `Disconnected: ${reason}` : 'Disconnected' - if (hasStatusActivity || reason) { - emitStatus(message, ts) - } - const nextInfo = { state: 'disconnected', detail: '' } - connectionInfo.value = nextInfo - scheduleChannelRefresh() - return - } - if (typeof detail === 'string') { - emitStatus(`Error: ${detail}`, ts) - const nextInfo = { state: 'error', detail } - connectionInfo.value = nextInfo - scheduleChannelRefresh() - return - } - if (detail && typeof detail === 'object') { - const target = detail.address || detail.port || detail.type || '' - emitStatus(`Connected: ${target}`, ts) - } - const nextInfo = { - state: 'connected', - detail: detail.address || detail.port || detail.type || '', - } - connectionInfo.value = nextInfo - scheduleChannelRefresh() - }) + bindCommBridgeSignals(obj) bindScriptBridgeSignals(obj) refreshPorts() refreshChannels() diff --git a/ui/frontend/src/composables/useCommBridgeSignals.test.ts b/ui/frontend/src/composables/useCommBridgeSignals.test.ts new file mode 100644 index 0000000..ce107e6 --- /dev/null +++ b/ui/frontend/src/composables/useCommBridgeSignals.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { useCommBridgeSignals } from './useCommBridgeSignals' + +function createSignal() { + const listeners: Array<(payload: any) => void> = [] + return { + connect(fn: (payload: any) => void) { + listeners.push(fn) + }, + emit(payload: any) { + listeners.forEach((fn) => fn(payload)) + }, + } +} + +describe('useCommBridgeSignals', () => { + it('binds rx/tx and frame/capture logs', () => { + const logs: Array<{ kind: string; payload: any }> = [] + const captures: any[] = [] + + const mgr = useCommBridgeSignals({ + parseBridgePayload: (payload) => ({ text: String(payload) }), + addCommLog: (kind, payload) => logs.push({ kind, payload }), + addCommBatch: () => {}, + ingestCaptureFrame: (payload) => captures.push(payload), + emitStatus: () => {}, + scheduleChannelRefresh: () => {}, + setConnectingFalse: () => {}, + onConnectionInfo: () => {}, + shouldEmitDisconnected: () => true, + }) + + const rx = createSignal() + const tx = createSignal() + const frame = createSignal() + const capture = createSignal() + + mgr.bindCommBridgeSignals({ + comm_rx: rx, + comm_tx: tx, + protocol_frame: frame, + capture_frame: capture, + }) + + rx.emit('r1') + tx.emit('t1') + frame.emit({ k: 1, ts: 1 }) + capture.emit({ c: 1, ts: 2 }) + + expect(logs.map((item) => item.kind)).toEqual(['RX', 'TX', 'FRAME', 'CAPTURE']) + expect(captures.length).toBe(1) + }) + + it('handles comm status branches', () => { + const infos: Array<{ state: string; detail: string }> = [] + const statuses: string[] = [] + let refreshCount = 0 + let connectingFalse = 0 + + const mgr = useCommBridgeSignals({ + parseBridgePayload: (payload) => payload, + addCommLog: () => {}, + addCommBatch: () => {}, + ingestCaptureFrame: () => {}, + emitStatus: (text) => statuses.push(text), + scheduleChannelRefresh: () => { + refreshCount += 1 + }, + setConnectingFalse: () => { + connectingFalse += 1 + }, + onConnectionInfo: (next) => infos.push(next), + shouldEmitDisconnected: () => true, + }) + + const commStatus = createSignal() + mgr.bindCommBridgeSignals({ comm_status: commStatus }) + + commStatus.emit({ payload: null, reason: 'timeout', ts: 1 }) + commStatus.emit({ payload: 'boom', ts: 2 }) + commStatus.emit({ payload: { address: '127.0.0.1:1' }, ts: 3 }) + + expect(connectingFalse).toBe(3) + expect(refreshCount).toBe(3) + expect(infos[0].state).toBe('disconnected') + expect(infos[1]).toEqual({ state: 'error', detail: 'boom' }) + expect(infos[2].state).toBe('connected') + expect(statuses.some((item) => item.includes('Disconnected'))).toBe(true) + expect(statuses).toContain('Error: boom') + }) +}) diff --git a/ui/frontend/src/composables/useCommBridgeSignals.ts b/ui/frontend/src/composables/useCommBridgeSignals.ts new file mode 100644 index 0000000..b95a6d0 --- /dev/null +++ b/ui/frontend/src/composables/useCommBridgeSignals.ts @@ -0,0 +1,81 @@ +type UseCommBridgeSignalsOptions = { + parseBridgePayload: (payload: any) => any + addCommLog: (kind: string, payload: any) => void + addCommBatch: (batch: any) => void + ingestCaptureFrame: (payload: any) => void + emitStatus: (text: string, ts: number) => void + scheduleChannelRefresh: () => void + setConnectingFalse: () => void + onConnectionInfo: (nextInfo: { state: string; detail: string }) => void + shouldEmitDisconnected: (reason: string) => boolean +} + +export function useCommBridgeSignals(options: UseCommBridgeSignalsOptions) { + function bindCommBridgeSignals(obj: any) { + if (!obj) return + if (obj.comm_rx && obj.comm_tx && typeof obj.comm_rx.connect === 'function' && typeof obj.comm_tx.connect === 'function') { + obj.comm_rx.connect((payload: any) => { + const parsed = options.parseBridgePayload(payload) + options.addCommLog('RX', parsed) + }) + obj.comm_tx.connect((payload: any) => { + const parsed = options.parseBridgePayload(payload) + options.addCommLog('TX', parsed) + }) + } else if (obj.comm_batch && typeof obj.comm_batch.connect === 'function') { + obj.comm_batch.connect((batch: any) => options.addCommBatch(batch)) + } + + if (obj.protocol_frame && typeof obj.protocol_frame.connect === 'function') { + obj.protocol_frame.connect((payload: any) => { + const ts = payload && payload.ts ? payload.ts : Date.now() / 1000 + options.addCommLog('FRAME', { text: JSON.stringify(payload), ts }) + }) + } + + if (obj.capture_frame && typeof obj.capture_frame.connect === 'function') { + obj.capture_frame.connect((payload: any) => { + const ts = payload && payload.ts ? payload.ts : Date.now() / 1000 + options.addCommLog('CAPTURE', { text: JSON.stringify(payload), ts }) + options.ingestCaptureFrame(payload) + }) + } + + if (obj.comm_status && typeof obj.comm_status.connect === 'function') { + obj.comm_status.connect((payload: any) => { + const detail = payload && payload.payload !== undefined ? payload.payload : payload + const ts = payload && payload.ts ? payload.ts : Date.now() / 1000 + options.setConnectingFalse() + if (!detail) { + const reason = payload && payload.reason ? String(payload.reason) : '' + const message = reason ? `Disconnected: ${reason}` : 'Disconnected' + if (options.shouldEmitDisconnected(reason)) { + options.emitStatus(message, ts) + } + options.onConnectionInfo({ state: 'disconnected', detail: '' }) + options.scheduleChannelRefresh() + return + } + if (typeof detail === 'string') { + options.emitStatus(`Error: ${detail}`, ts) + options.onConnectionInfo({ state: 'error', detail }) + options.scheduleChannelRefresh() + return + } + if (detail && typeof detail === 'object') { + const target = detail.address || detail.port || detail.type || '' + options.emitStatus(`Connected: ${target}`, ts) + } + options.onConnectionInfo({ + state: 'connected', + detail: detail.address || detail.port || detail.type || '', + }) + options.scheduleChannelRefresh() + }) + } + } + + return { + bindCommBridgeSignals, + } +} From a3556b0a0d8c772d8d7e43158de047cd8b6ee0bf Mon Sep 17 00:00:00 2001 From: 11cookies11 Date: Sun, 1 Mar 2026 18:14:35 +0800 Subject: [PATCH 053/145] refactor(frontend): extract comm and window chrome bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [中文] - 变更内容: - extract comm and window chrome bindings - 影响范围: - 详见本次提交改动文件 - 兼容性/行为变化: - 无破坏性接口变更(如有以代码为准) - 依赖/环境: - 无新增依赖(如有以代码为准) - 验证: - 已完成对应构建/测试(如有以提交记录为准) Refs: - 无 [English] - Changes: - extract comm and window chrome bindings - Impact: - See files changed in this commit - Compatibility/Behavior changes: - No breaking API changes (unless code indicates otherwise) - Dependencies/Environment: - No new dependencies (unless code indicates otherwise) - Verification: - Relevant build/tests executed where applicable Refs: - N/A --- .where-agent-progress.md | 4 +- ui/frontend/src/App.vue | 190 ++--------------- .../src/composables/useWindowChrome.test.ts | 90 ++++++++ .../src/composables/useWindowChrome.ts | 198 ++++++++++++++++++ 4 files changed, 311 insertions(+), 171 deletions(-) create mode 100644 ui/frontend/src/composables/useWindowChrome.test.ts create mode 100644 ui/frontend/src/composables/useWindowChrome.ts diff --git a/.where-agent-progress.md b/.where-agent-progress.md index d038a45..0744dc5 100644 --- a/.where-agent-progress.md +++ b/.where-agent-progress.md @@ -4,8 +4,8 @@ - [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口 - [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射 - [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 -- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件逻辑到 composables +- [~] F. App 逻辑下沉:已接管 channel 端口处理与串口连接入口,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables - [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 - [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 - [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流 -- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试 diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index e01889c..0f7344c 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -32,6 +32,7 @@ import { useScriptLogHelpers } from './composables/useScriptLogHelpers' import { useYamlSearch } from './composables/useYamlSearch' import { useScriptBridgeSignals } from './composables/useScriptBridgeSignals' import { useCommBridgeSignals } from './composables/useCommBridgeSignals' +import { useWindowChrome } from './composables/useWindowChrome' import { useSettingsState } from './composables/useSettingsState' import { useSettingsPersistence } from './composables/useSettingsPersistence' import { useSettingsBridge } from './composables/useSettingsBridge' @@ -97,12 +98,6 @@ const appendCR = ref(true) const appendLF = ref(true) const loopSend = ref(false) const isConnecting = ref(false) -const draggingWindow = ref(false) -const dragArmed = ref(false) -const dragStarted = ref(false) -const dragStart = ref({ x: 0, y: 0 }) -const snapPreview = ref('') -const enableSnapPreview = ref(false) const portPlaceholder = serialDefaults.portPlaceholder const channelDialogOpen = ref(false) const channelDialogMode = ref('create') @@ -421,6 +416,25 @@ const { bindCommBridgeSignals } = useCommBridgeSignals({ shouldEmitDisconnected: (reason) => hasStatusActivity || Boolean(reason), }) +const { + draggingWindow, + snapPreview, + armWindowMove, + maybeStartWindowMove, + minimizeWindow, + toggleMaximize, + closeWindow, + applyWindowSnap, + showSystemMenu, + startResize, + disposeWindowChrome, +} = useWindowChrome({ + bridge, + sidebarRef, + lockPageScroll, + unlockPageScroll, +}) + function setSettingsTab(tab) { settingsTab.value = tab } @@ -524,8 +538,6 @@ let channelRefreshTimer = null let channelUpdateRaf = 0 let pendingChannelItems = null let scriptLogScrollRaf = 0 -let snapPreviewRaf = 0 -let pendingSnapPreview = null let attachedBridge = null const SCROLL_LOCK_SELECTORS = [ '.page', @@ -1232,6 +1244,7 @@ onBeforeUnmount(() => { window.cancelAnimationFrame(scriptLogScrollRaf) scriptLogScrollRaf = 0 } + disposeWindowChrome() destroyYamlEditor() window.removeEventListener('keydown', handleGlobalKeydown) }) @@ -1318,72 +1331,6 @@ watch( } ) -function armWindowMove(event) { - if (!event) return - dragArmed.value = true - dragStarted.value = false - dragStart.value = { x: event.screenX, y: event.screenY } -} - -function maybeStartWindowMove(event) { - if (!dragArmed.value || dragStarted.value || !event) return - const dx = Math.abs(event.screenX - dragStart.value.x) - const dy = Math.abs(event.screenY - dragStart.value.y) - if (dx < 10 && dy < 10) return - lockSidebarWidth() - if (bridge.value) { - if (bridge.value.window_start_move_at) { - bridge.value.window_start_move_at(Math.round(event.screenX), Math.round(event.screenY)) - } else { - bridge.value.window_start_move() - } - } - dragStarted.value = true - draggingWindow.value = true - document.body.classList.add('dragging-window') - lockPageScroll() - snapPreview.value = '' - attachDragListeners() -} - -function minimizeWindow() { - if (bridge.value) { - bridge.value.window_minimize() - } -} - -function toggleMaximize() { - if (bridge.value) { - bridge.value.window_toggle_maximize() - } -} - -function closeWindow() { - if (bridge.value) { - bridge.value.window_close() - } -} - -function applyWindowSnap(event) { - if (bridge.value && event && dragStarted.value) { - bridge.value.window_apply_snap(Math.round(event.screenX), Math.round(event.screenY)) - } - clearDragState() -} - -function showSystemMenu(event) { - if (bridge.value && event) { - bridge.value.window_show_system_menu(Math.round(event.screenX), Math.round(event.screenY)) - } -} - -function startResize(edge, event) { - if (!bridge.value || !edge || !event) return - document.body.classList.add('resizing') - lockPageScroll() - bridge.value.window_start_resize(edge) -} - function selectPort(item) { if (!item) return selectChannelPort(item) @@ -1453,101 +1400,6 @@ const { loadSettings, saveSettings, chooseDslWorkspace } = useSettingsBridge({ commitSettingsSnapshot, }) -function scheduleSnapPreview(event) { - if (!event) return - pendingSnapPreview = { - x: event.clientX, - y: event.clientY, - screenX: event.screenX, - screenY: event.screenY, - buttons: event.buttons, - } - if (snapPreviewRaf) return - snapPreviewRaf = window.requestAnimationFrame(() => { - snapPreviewRaf = 0 - if (!pendingSnapPreview) return - const payload = pendingSnapPreview - pendingSnapPreview = null - updateSnapPreview(payload) - }) -} - -function updateSnapPreview(payload) { - if (!draggingWindow.value || !payload) return - if (payload.buttons !== 1) { - applyWindowSnap(payload) - return - } - if (!enableSnapPreview.value) { - snapPreview.value = '' - return - } - const margin = 24 - const x = payload.x - const y = payload.y - const width = window.innerWidth - if (y <= margin) { - snapPreview.value = 'max' - } else if (x <= margin) { - snapPreview.value = 'left' - } else if (x >= width - margin) { - snapPreview.value = 'right' - } else { - snapPreview.value = '' - } -} - -function handleDragEnd(event) { - if (!draggingWindow.value) return - applyWindowSnap(event) -} - -function attachDragListeners() { - window.addEventListener('mousemove', scheduleSnapPreview) - window.addEventListener('mouseup', handleDragEnd) - window.addEventListener('blur', handleDragCancel) - document.addEventListener('visibilitychange', handleDragCancel) -} - -function detachDragListeners() { - window.removeEventListener('mousemove', scheduleSnapPreview) - window.removeEventListener('mouseup', handleDragEnd) - window.removeEventListener('blur', handleDragCancel) - document.removeEventListener('visibilitychange', handleDragCancel) -} - -function handleDragCancel() { - clearDragState() -} - -function clearDragState() { - draggingWindow.value = false - dragArmed.value = false - dragStarted.value = false - snapPreview.value = '' - pendingSnapPreview = null - unlockSidebarWidth() - if (snapPreviewRaf) { - window.cancelAnimationFrame(snapPreviewRaf) - snapPreviewRaf = 0 - } - document.body.classList.remove('dragging-window') - document.body.classList.remove('resizing') - unlockPageScroll() - detachDragListeners() -} - -function lockSidebarWidth() { - const sidebar = sidebarRef.value - if (!sidebar || !sidebar.getBoundingClientRect) return - const width = Math.round(sidebar.getBoundingClientRect().width) - document.documentElement.style.setProperty('--sidebar-width', `${width}px`) -} - -function unlockSidebarWidth() { - document.documentElement.style.removeProperty('--sidebar-width') -} -