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('代理启动失败,请检查端口占用和参数配置。') }}
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ tr('驱动') }}
-
-
-
-
-
-
-
-
+
-
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tr('驱动') }}
+
+
+
+
+
+
+
+
+
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() {
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ tr('串口参数') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tr('串口参数') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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() {
-
-
-
-
-
-
{{ tr('解析失败') }}
-
{{ uiRuntime.parseError.message }}
-
Path: {{ uiRuntime.parseError.path }}
-
- Line {{ uiRuntime.parseError.line }}, Column {{ uiRuntime.parseError.column || 0 }}
-
-
-
-
-
-
{{ tr('暂无可渲染的 UI 配置') }}
-
-
-
+
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 @@
+
+
+
+
+
+
+
+
+
{{ tr('解析失败') }}
+
{{ runtime.parseError.message }}
+
Path: {{ runtime.parseError.path }}
+
+ Line {{ runtime.parseError.line }}, Column {{ runtime.parseError.column || 0 }}
+
+
+
+
+
+
{{ tr('暂无可渲染的 UI 配置') }}
+
+
+
+
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')
-}
-
diff --git a/ui/frontend/src/composables/useWindowChrome.test.ts b/ui/frontend/src/composables/useWindowChrome.test.ts
new file mode 100644
index 0000000..d64baa3
--- /dev/null
+++ b/ui/frontend/src/composables/useWindowChrome.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+import { useWindowChrome } from './useWindowChrome'
+
+describe('useWindowChrome', () => {
+ it('starts window move when drag threshold reached', () => {
+ let moveAt = 0
+ let lockCount = 0
+ let unlockCount = 0
+ const sidebarRef = ref({
+ getBoundingClientRect: () => ({ width: 240 }),
+ })
+ const chrome = useWindowChrome({
+ bridge: ref({
+ window_start_move_at: () => {
+ moveAt += 1
+ },
+ }),
+ sidebarRef,
+ lockPageScroll: () => {
+ lockCount += 1
+ },
+ unlockPageScroll: () => {
+ unlockCount += 1
+ },
+ })
+
+ chrome.armWindowMove({ screenX: 10, screenY: 10 })
+ chrome.maybeStartWindowMove({ screenX: 30, screenY: 30 })
+
+ expect(moveAt).toBe(1)
+ expect(lockCount).toBe(1)
+ expect(chrome.draggingWindow.value).toBe(true)
+
+ chrome.applyWindowSnap({ screenX: 31, screenY: 32 })
+ expect(chrome.draggingWindow.value).toBe(false)
+ expect(unlockCount).toBeGreaterThanOrEqual(1)
+ })
+
+ it('invokes bridge actions for window controls', () => {
+ let minimized = 0
+ let maximized = 0
+ let closed = 0
+ let menu = 0
+ let resized = 0
+ let snapped = 0
+
+ const chrome = useWindowChrome({
+ bridge: ref({
+ window_minimize: () => {
+ minimized += 1
+ },
+ window_toggle_maximize: () => {
+ maximized += 1
+ },
+ window_close: () => {
+ closed += 1
+ },
+ window_show_system_menu: () => {
+ menu += 1
+ },
+ window_start_resize: () => {
+ resized += 1
+ },
+ window_apply_snap: () => {
+ snapped += 1
+ },
+ }),
+ sidebarRef: ref(null),
+ lockPageScroll: () => {},
+ unlockPageScroll: () => {},
+ })
+
+ chrome.armWindowMove({ screenX: 1, screenY: 1 })
+ chrome.maybeStartWindowMove({ screenX: 20, screenY: 20 })
+ chrome.applyWindowSnap({ screenX: 50, screenY: 50 })
+ chrome.minimizeWindow()
+ chrome.toggleMaximize()
+ chrome.closeWindow()
+ chrome.showSystemMenu({ screenX: 4, screenY: 5 })
+ chrome.startResize('left', { screenX: 1, screenY: 1 })
+
+ expect(minimized).toBe(1)
+ expect(maximized).toBe(1)
+ expect(closed).toBe(1)
+ expect(menu).toBe(1)
+ expect(resized).toBe(1)
+ expect(snapped).toBe(1)
+ })
+})
diff --git a/ui/frontend/src/composables/useWindowChrome.ts b/ui/frontend/src/composables/useWindowChrome.ts
new file mode 100644
index 0000000..ec1d923
--- /dev/null
+++ b/ui/frontend/src/composables/useWindowChrome.ts
@@ -0,0 +1,198 @@
+import { ref, type Ref } from 'vue'
+
+type UseWindowChromeOptions = {
+ bridge: Ref
+ sidebarRef: Ref
+ lockPageScroll: () => void
+ unlockPageScroll: () => void
+}
+
+export function useWindowChrome(options: UseWindowChromeOptions) {
+ 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)
+ let snapPreviewRaf = 0
+ let pendingSnapPreview: any = null
+
+ function lockSidebarWidth() {
+ const sidebar = options.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')
+ }
+
+ function armWindowMove(event: any) {
+ if (!event) return
+ dragArmed.value = true
+ dragStarted.value = false
+ dragStart.value = { x: event.screenX, y: event.screenY }
+ }
+
+ function maybeStartWindowMove(event: any) {
+ 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 (options.bridge.value) {
+ if (options.bridge.value.window_start_move_at) {
+ options.bridge.value.window_start_move_at(Math.round(event.screenX), Math.round(event.screenY))
+ } else if (options.bridge.value.window_start_move) {
+ options.bridge.value.window_start_move()
+ }
+ }
+ dragStarted.value = true
+ draggingWindow.value = true
+ document.body.classList.add('dragging-window')
+ options.lockPageScroll()
+ snapPreview.value = ''
+ attachDragListeners()
+ }
+
+ function minimizeWindow() {
+ if (options.bridge.value && options.bridge.value.window_minimize) {
+ options.bridge.value.window_minimize()
+ }
+ }
+
+ function toggleMaximize() {
+ if (options.bridge.value && options.bridge.value.window_toggle_maximize) {
+ options.bridge.value.window_toggle_maximize()
+ }
+ }
+
+ function closeWindow() {
+ if (options.bridge.value && options.bridge.value.window_close) {
+ options.bridge.value.window_close()
+ }
+ }
+
+ function applyWindowSnap(event: any) {
+ if (options.bridge.value && event && dragStarted.value && options.bridge.value.window_apply_snap) {
+ options.bridge.value.window_apply_snap(Math.round(event.screenX), Math.round(event.screenY))
+ }
+ clearDragState()
+ }
+
+ function showSystemMenu(event: any) {
+ if (options.bridge.value && event && options.bridge.value.window_show_system_menu) {
+ options.bridge.value.window_show_system_menu(Math.round(event.screenX), Math.round(event.screenY))
+ }
+ }
+
+ function startResize(edge: string, event: any) {
+ if (!options.bridge.value || !edge || !event || !options.bridge.value.window_start_resize) return
+ document.body.classList.add('resizing')
+ options.lockPageScroll()
+ options.bridge.value.window_start_resize(edge)
+ }
+
+ function scheduleSnapPreview(event: any) {
+ 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: any) {
+ 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: any) {
+ 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')
+ options.unlockPageScroll()
+ detachDragListeners()
+ }
+
+ function disposeWindowChrome() {
+ clearDragState()
+ }
+
+ return {
+ draggingWindow,
+ snapPreview,
+ armWindowMove,
+ maybeStartWindowMove,
+ minimizeWindow,
+ toggleMaximize,
+ closeWindow,
+ applyWindowSnap,
+ showSystemMenu,
+ startResize,
+ disposeWindowChrome,
+ }
+}
From 8cab2177c4b4c4aee51c5626c7c742bc0531b0ae Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:21:44 +0800
Subject: [PATCH 054/145] refactor(frontend): extract channel connection
actions composable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- extract channel connection actions composable
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- extract channel connection actions 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 | 2 +-
ui/frontend/src/App.vue | 48 ++----
.../useChannelConnectionActions.test.ts | 138 ++++++++++++++++++
.../useChannelConnectionActions.ts | 60 ++++++++
4 files changed, 212 insertions(+), 36 deletions(-)
create mode 100644 ui/frontend/src/composables/useChannelConnectionActions.test.ts
create mode 100644 ui/frontend/src/composables/useChannelConnectionActions.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index 0744dc5..e1a1356 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 0f7344c..6efce25 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 { useChannelConnectionActions } from './composables/useChannelConnectionActions'
import { useChannelDialog } from './composables/useChannelDialog'
import { usePayloadSender } from './composables/usePayloadSender'
import { useYamlDocumentOps } from './composables/useYamlDocumentOps'
@@ -210,6 +211,18 @@ const channelCards = computed(() => {
})
const isConnected = computed(() => connectionInfo.value.state === 'connected')
+const { selectPort, connectSerial, connectTcp, connectPrimary, disconnect } = useChannelConnectionActions({
+ bridge,
+ isConnecting,
+ isConnected,
+ selectedPort,
+ baud,
+ tcpHost,
+ tcpPort,
+ channelMode,
+ resolveSerialPort,
+ selectChannelPort,
+})
const sliceTail = (items, limit) => {
if (!Array.isArray(items)) return []
@@ -1151,36 +1164,6 @@ function handleChannelRefresh() {
refreshPorts()
}
-function connectSerial() {
- if (!bridge.value) return
- if (isConnecting.value || isConnected.value) return
- const targetPort = resolveSerialPort(selectedPort.value)
- if (!targetPort) return
- selectedPort.value = targetPort
- isConnecting.value = true
- bridge.value.connect_serial(targetPort, Number(baud.value))
-}
-
-function connectTcp() {
- if (!bridge.value) return
- if (isConnecting.value || isConnected.value) return
- isConnecting.value = true
- bridge.value.connect_tcp(tcpHost.value, Number(tcpPort.value))
-}
-
-function connectPrimary() {
- if (channelMode.value === 'tcp') {
- connectTcp()
- } else {
- connectSerial()
- }
-}
-
-function disconnect() {
- if (!bridge.value) return
- bridge.value.disconnect()
-}
-
function attachBridge(obj) {
if (!obj || attachedBridge === obj) return
attachedBridge = obj
@@ -1331,11 +1314,6 @@ watch(
}
)
-function selectPort(item) {
- if (!item) return
- selectChannelPort(item)
- channelMode.value = 'serial'
-}
function handleGlobalKeydown(event) {
if (!event) return
if (event.key === 'Escape') {
diff --git a/ui/frontend/src/composables/useChannelConnectionActions.test.ts b/ui/frontend/src/composables/useChannelConnectionActions.test.ts
new file mode 100644
index 0000000..ac951b5
--- /dev/null
+++ b/ui/frontend/src/composables/useChannelConnectionActions.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest'
+import { computed, ref } from 'vue'
+import { useChannelConnectionActions } from './useChannelConnectionActions'
+
+describe('useChannelConnectionActions', () => {
+ it('selects port and switches to serial mode', () => {
+ const selected = ref('')
+ const mode = ref('tcp')
+ const actions = useChannelConnectionActions({
+ bridge: ref(null),
+ isConnecting: ref(false),
+ isConnected: computed(() => false),
+ selectedPort: selected,
+ baud: ref(115200),
+ tcpHost: ref('127.0.0.1'),
+ tcpPort: ref(9000),
+ channelMode: mode,
+ resolveSerialPort: (value: string) => value,
+ selectChannelPort: (item: string) => {
+ selected.value = item
+ },
+ })
+
+ actions.selectPort('COM7')
+ expect(selected.value).toBe('COM7')
+ expect(mode.value).toBe('serial')
+ })
+
+ it('connects serial with normalized port and sets connecting state', () => {
+ const selected = ref('com3')
+ const isConnecting = ref(false)
+ let calledPort = ''
+ let calledBaud = 0
+
+ const actions = useChannelConnectionActions({
+ bridge: ref({
+ connect_serial: (port: string, baud: number) => {
+ calledPort = port
+ calledBaud = baud
+ },
+ }),
+ isConnecting,
+ isConnected: computed(() => false),
+ selectedPort: selected,
+ baud: ref(57600),
+ tcpHost: ref('127.0.0.1'),
+ tcpPort: ref(9000),
+ channelMode: ref('serial'),
+ resolveSerialPort: (value: string) => String(value).toUpperCase(),
+ selectChannelPort: () => {},
+ })
+
+ actions.connectSerial()
+ expect(selected.value).toBe('COM3')
+ expect(calledPort).toBe('COM3')
+ expect(calledBaud).toBe(57600)
+ expect(isConnecting.value).toBe(true)
+ })
+
+ it('skips connect when already connecting or connected', () => {
+ let serialCalls = 0
+ let tcpCalls = 0
+ const isConnecting = ref(true)
+ const connected = ref(false)
+ const actions = useChannelConnectionActions({
+ bridge: ref({
+ connect_serial: () => {
+ serialCalls += 1
+ },
+ connect_tcp: () => {
+ tcpCalls += 1
+ },
+ }),
+ isConnecting,
+ isConnected: computed(() => connected.value),
+ selectedPort: ref('COM1'),
+ baud: ref(115200),
+ tcpHost: ref('127.0.0.1'),
+ tcpPort: ref(9000),
+ channelMode: ref('serial'),
+ resolveSerialPort: (value: string) => value,
+ selectChannelPort: () => {},
+ })
+
+ actions.connectSerial()
+ actions.connectTcp()
+ expect(serialCalls).toBe(0)
+ expect(tcpCalls).toBe(0)
+
+ isConnecting.value = false
+ connected.value = true
+ actions.connectSerial()
+ actions.connectTcp()
+ expect(serialCalls).toBe(0)
+ expect(tcpCalls).toBe(0)
+ })
+
+ it('connectPrimary switches by mode and disconnect delegates bridge', () => {
+ let serialCalls = 0
+ let tcpCalls = 0
+ let disconnectCalls = 0
+ const mode = ref('serial')
+ const actions = useChannelConnectionActions({
+ bridge: ref({
+ connect_serial: () => {
+ serialCalls += 1
+ },
+ connect_tcp: () => {
+ tcpCalls += 1
+ },
+ disconnect: () => {
+ disconnectCalls += 1
+ },
+ }),
+ isConnecting: ref(false),
+ isConnected: computed(() => false),
+ selectedPort: ref('COM4'),
+ baud: ref(115200),
+ tcpHost: ref('127.0.0.1'),
+ tcpPort: ref(9010),
+ channelMode: mode,
+ resolveSerialPort: (value: string) => value,
+ selectChannelPort: () => {},
+ })
+
+ actions.connectPrimary()
+ expect(serialCalls).toBe(1)
+ expect(tcpCalls).toBe(0)
+
+ mode.value = 'tcp'
+ actions.connectPrimary()
+ expect(serialCalls).toBe(1)
+ expect(tcpCalls).toBe(0)
+
+ actions.disconnect()
+ expect(disconnectCalls).toBe(1)
+ })
+})
diff --git a/ui/frontend/src/composables/useChannelConnectionActions.ts b/ui/frontend/src/composables/useChannelConnectionActions.ts
new file mode 100644
index 0000000..a936420
--- /dev/null
+++ b/ui/frontend/src/composables/useChannelConnectionActions.ts
@@ -0,0 +1,60 @@
+import type { Ref } from 'vue'
+
+type UseChannelConnectionActionsOptions = {
+ bridge: Ref
+ isConnecting: Ref
+ isConnected: Readonly[>
+ selectedPort: Ref
+ baud: Ref
+ tcpHost: Ref
+ tcpPort: Ref
+ channelMode: Ref
+ resolveSerialPort: (value: string) => string
+ selectChannelPort: (item: string) => void
+}
+
+export function useChannelConnectionActions(options: UseChannelConnectionActionsOptions) {
+ function selectPort(item: string) {
+ if (!item) return
+ options.selectChannelPort(item)
+ options.channelMode.value = 'serial'
+ }
+
+ function connectSerial() {
+ if (!options.bridge.value) return
+ if (options.isConnecting.value || options.isConnected.value) return
+ const targetPort = options.resolveSerialPort(options.selectedPort.value)
+ if (!targetPort) return
+ options.selectedPort.value = targetPort
+ options.isConnecting.value = true
+ options.bridge.value.connect_serial(targetPort, Number(options.baud.value))
+ }
+
+ function connectTcp() {
+ if (!options.bridge.value) return
+ if (options.isConnecting.value || options.isConnected.value) return
+ options.isConnecting.value = true
+ options.bridge.value.connect_tcp(options.tcpHost.value, Number(options.tcpPort.value))
+ }
+
+ function connectPrimary() {
+ if (options.channelMode.value === 'tcp') {
+ connectTcp()
+ return
+ }
+ connectSerial()
+ }
+
+ function disconnect() {
+ if (!options.bridge.value) return
+ options.bridge.value.disconnect()
+ }
+
+ return {
+ selectPort,
+ connectSerial,
+ connectTcp,
+ connectPrimary,
+ disconnect,
+ }
+}
From 705ae1ccf403d58af81469d05a5bcfc5ae7e9934 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:26:25 +0800
Subject: [PATCH 055/145] refactor(frontend): extract channel sync composable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- extract channel sync composable
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- extract channel sync 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 | 2 +-
ui/frontend/src/App.vue | 71 ++------------
.../src/composables/useChannelSync.test.ts | 89 +++++++++++++++++
ui/frontend/src/composables/useChannelSync.ts | 95 +++++++++++++++++++
4 files changed, 195 insertions(+), 62 deletions(-)
create mode 100644 ui/frontend/src/composables/useChannelSync.test.ts
create mode 100644 ui/frontend/src/composables/useChannelSync.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index e1a1356..75b3ddb 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [~] F. App 逻辑下沉:已接管 channel 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 6efce25..fdc8a2d 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 { useChannelConnectionActions } from './composables/useChannelConnectionActions'
+import { useChannelSync } from './composables/useChannelSync'
import { useChannelDialog } from './composables/useChannelDialog'
import { usePayloadSender } from './composables/usePayloadSender'
import { useYamlDocumentOps } from './composables/useYamlDocumentOps'
@@ -223,6 +224,14 @@ const { selectPort, connectSerial, connectTcp, connectPrimary, disconnect } = us
resolveSerialPort,
selectChannelPort,
})
+const { setChannels, scheduleSetChannels, refreshChannels, scheduleChannelRefresh, disposeChannelSync } =
+ useChannelSync({
+ bridge,
+ channels,
+ connectionInfo,
+ isConnecting,
+ emitStatus,
+ })
const sliceTail = (items, limit) => {
if (!Array.isArray(items)) return []
@@ -547,9 +556,6 @@ let scriptTimer = null
let yamlEditor = null
let yamlEditorUpdating = false
let scriptVarTimer = null
-let channelRefreshTimer = null
-let channelUpdateRaf = 0
-let pendingChannelItems = null
let scriptLogScrollRaf = 0
let attachedBridge = null
const SCROLL_LOCK_SELECTORS = [
@@ -1109,56 +1115,6 @@ function refreshPorts() {
})
}
-function setChannels(items) {
- channels.value = Array.isArray(items) ? items : []
- const primary = channels.value[0]
- if (!primary) return
- if (primary.status === 'connected') {
- const target = primary.address || primary.port || primary.type || ''
- emitStatus(`Connected: ${target}`, Date.now() / 1000)
- connectionInfo.value = {
- state: 'connected',
- detail: primary.address || primary.port || primary.type || '',
- }
- hasStatusActivity = true
- isConnecting.value = false
- return
- }
- if (primary.status === 'error') {
- emitStatus(`Error: ${primary.error || ''}`, Date.now() / 1000)
- connectionInfo.value = { state: 'error', detail: primary.error || '' }
- isConnecting.value = false
- return
- }
-}
-
-function scheduleSetChannels(items) {
- pendingChannelItems = items
- if (channelUpdateRaf) return
- channelUpdateRaf = window.requestAnimationFrame(() => {
- channelUpdateRaf = 0
- const next = pendingChannelItems
- pendingChannelItems = null
- setChannels(next)
- })
-}
-
-function refreshChannels() {
- if (!bridge.value || !bridge.value.list_channels) return
- withResult(bridge.value.list_channels(), (items) => {
- setChannels(items)
- })
-}
-
-
-function scheduleChannelRefresh() {
- if (channelRefreshTimer) return
- channelRefreshTimer = window.setTimeout(() => {
- channelRefreshTimer = null
- refreshChannels()
- }, 150)
-}
-
function handleChannelRefresh() {
refreshChannels()
refreshPorts()
@@ -1215,14 +1171,7 @@ onBeforeUnmount(() => {
window.cancelAnimationFrame(logFlushHandle)
logFlushHandle = 0
}
- if (channelRefreshTimer) {
- window.clearTimeout(channelRefreshTimer)
- channelRefreshTimer = null
- }
- if (channelUpdateRaf) {
- window.cancelAnimationFrame(channelUpdateRaf)
- channelUpdateRaf = 0
- }
+ disposeChannelSync()
if (scriptLogScrollRaf) {
window.cancelAnimationFrame(scriptLogScrollRaf)
scriptLogScrollRaf = 0
diff --git a/ui/frontend/src/composables/useChannelSync.test.ts b/ui/frontend/src/composables/useChannelSync.test.ts
new file mode 100644
index 0000000..f97a4d3
--- /dev/null
+++ b/ui/frontend/src/composables/useChannelSync.test.ts
@@ -0,0 +1,89 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { useChannelSync } from './useChannelSync'
+
+function createSync(bridgeValue: any = null) {
+ const bridge = ref(bridgeValue)
+ const channels = ref([])
+ const connectionInfo = ref({ state: 'disconnected', detail: '' })
+ const isConnecting = ref(false)
+ const statuses: string[] = []
+ const sync = useChannelSync({
+ bridge,
+ channels,
+ connectionInfo,
+ isConnecting,
+ emitStatus: (text: string) => statuses.push(text),
+ })
+ return { bridge, channels, connectionInfo, isConnecting, statuses, sync }
+}
+
+describe('useChannelSync', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('updates connection info for connected and error channel', () => {
+ const { channels, connectionInfo, isConnecting, statuses, sync } = createSync()
+ isConnecting.value = true
+
+ sync.setChannels([{ status: 'connected', address: '127.0.0.1:9000' }])
+ expect(channels.value).toHaveLength(1)
+ expect(connectionInfo.value).toEqual({ state: 'connected', detail: '127.0.0.1:9000' })
+ expect(isConnecting.value).toBe(false)
+ expect(statuses[0]).toContain('Connected:')
+
+ isConnecting.value = true
+ sync.setChannels([{ status: 'error', error: 'timeout' }])
+ expect(connectionInfo.value).toEqual({ state: 'error', detail: 'timeout' })
+ expect(isConnecting.value).toBe(false)
+ expect(statuses[1]).toBe('Error: timeout')
+ })
+
+ it('coalesces scheduleSetChannels updates to the latest item', () => {
+ let rafCallback: FrameRequestCallback | null = null
+ const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
+ rafCallback = cb
+ return 1
+ })
+
+ const { connectionInfo, sync } = createSync()
+ sync.scheduleSetChannels([{ status: 'error', error: 'old' }])
+ sync.scheduleSetChannels([{ status: 'connected', port: 'COM3' }])
+ expect(rafSpy).toHaveBeenCalledTimes(1)
+ expect(connectionInfo.value.state).toBe('disconnected')
+
+ rafCallback?.(0)
+ expect(connectionInfo.value).toEqual({ state: 'connected', detail: 'COM3' })
+ })
+
+ it('debounces channel refresh by timeout', () => {
+ vi.useFakeTimers()
+ let listCalls = 0
+ const { sync, channels } = createSync({
+ list_channels: () => {
+ listCalls += 1
+ return [{ status: 'connected', type: 'serial' }]
+ },
+ })
+
+ sync.scheduleChannelRefresh()
+ sync.scheduleChannelRefresh()
+ vi.advanceTimersByTime(149)
+ expect(listCalls).toBe(0)
+
+ vi.advanceTimersByTime(1)
+ expect(listCalls).toBe(1)
+ expect(channels.value[0]?.status).toBe('connected')
+ })
+
+ it('refreshes channels and supports promise bridge result', async () => {
+ const { sync, channels } = createSync({
+ list_channels: () => Promise.resolve([{ status: 'connected', address: 'A' }]),
+ })
+ sync.refreshChannels()
+ await Promise.resolve()
+ expect(channels.value[0]?.address).toBe('A')
+ })
+})
diff --git a/ui/frontend/src/composables/useChannelSync.ts b/ui/frontend/src/composables/useChannelSync.ts
new file mode 100644
index 0000000..57a86ad
--- /dev/null
+++ b/ui/frontend/src/composables/useChannelSync.ts
@@ -0,0 +1,95 @@
+import type { Ref } from 'vue'
+
+type ConnectionInfo = {
+ state: string
+ detail: string
+}
+
+type UseChannelSyncOptions = {
+ bridge: Ref
+ channels: Ref
+ connectionInfo: Ref
+ isConnecting: Ref
+ emitStatus: (text: string, ts: number) => void
+}
+
+function withResult(value: any, handler: (payload: any) => void) {
+ if (value && typeof value.then === 'function') {
+ value.then(handler)
+ return
+ }
+ handler(value)
+}
+
+export function useChannelSync(options: UseChannelSyncOptions) {
+ let channelRefreshTimer: number | null = null
+ let channelUpdateRaf = 0
+ let pendingChannelItems: any = null
+
+ function setChannels(items: any) {
+ options.channels.value = Array.isArray(items) ? items : []
+ const primary = options.channels.value[0]
+ if (!primary) return
+ if (primary.status === 'connected') {
+ const target = primary.address || primary.port || primary.type || ''
+ options.emitStatus(`Connected: ${target}`, Date.now() / 1000)
+ options.connectionInfo.value = {
+ state: 'connected',
+ detail: primary.address || primary.port || primary.type || '',
+ }
+ options.isConnecting.value = false
+ return
+ }
+ if (primary.status === 'error') {
+ options.emitStatus(`Error: ${primary.error || ''}`, Date.now() / 1000)
+ options.connectionInfo.value = { state: 'error', detail: primary.error || '' }
+ options.isConnecting.value = false
+ }
+ }
+
+ function scheduleSetChannels(items: any) {
+ pendingChannelItems = items
+ if (channelUpdateRaf) return
+ channelUpdateRaf = window.requestAnimationFrame(() => {
+ channelUpdateRaf = 0
+ const next = pendingChannelItems
+ pendingChannelItems = null
+ setChannels(next)
+ })
+ }
+
+ function refreshChannels() {
+ if (!options.bridge.value || !options.bridge.value.list_channels) return
+ withResult(options.bridge.value.list_channels(), (items) => {
+ setChannels(items)
+ })
+ }
+
+ function scheduleChannelRefresh() {
+ if (channelRefreshTimer) return
+ channelRefreshTimer = window.setTimeout(() => {
+ channelRefreshTimer = null
+ refreshChannels()
+ }, 150)
+ }
+
+ function disposeChannelSync() {
+ if (channelRefreshTimer) {
+ window.clearTimeout(channelRefreshTimer)
+ channelRefreshTimer = null
+ }
+ if (channelUpdateRaf) {
+ window.cancelAnimationFrame(channelUpdateRaf)
+ channelUpdateRaf = 0
+ }
+ pendingChannelItems = null
+ }
+
+ return {
+ setChannels,
+ scheduleSetChannels,
+ refreshChannels,
+ scheduleChannelRefresh,
+ disposeChannelSync,
+ }
+}
From 11b4c0a3c0cca7ae5814e377f8e5db5f5e25e8e2 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:30:14 +0800
Subject: [PATCH 056/145] refactor(frontend): extract bridge bootstrap
composable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- extract bridge bootstrap composable
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- extract bridge bootstrap 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 | 2 +-
ui/frontend/src/App.vue | 39 ++----
.../composables/useBridgeBootstrap.test.ts | 116 ++++++++++++++++++
.../src/composables/useBridgeBootstrap.ts | 69 +++++++++++
4 files changed, 199 insertions(+), 27 deletions(-)
create mode 100644 ui/frontend/src/composables/useBridgeBootstrap.test.ts
create mode 100644 ui/frontend/src/composables/useBridgeBootstrap.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index 75b3ddb..5656225 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [~] F. App 逻辑下沉:已接管 channel 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index fdc8a2d..13e66ef 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -39,6 +39,7 @@ import { useSettingsState } from './composables/useSettingsState'
import { useSettingsPersistence } from './composables/useSettingsPersistence'
import { useSettingsBridge } from './composables/useSettingsBridge'
import { useProtocolManager } from './composables/useProtocolManager'
+import { useBridgeBootstrap } from './composables/useBridgeBootstrap'
const bridge = ref(null)
const sidebarRef = ref(null)
@@ -557,7 +558,6 @@ let yamlEditor = null
let yamlEditorUpdating = false
let scriptVarTimer = null
let scriptLogScrollRaf = 0
-let attachedBridge = null
const SCROLL_LOCK_SELECTORS = [
'.page',
'.modal-body',
@@ -1120,32 +1120,8 @@ function handleChannelRefresh() {
refreshPorts()
}
-function attachBridge(obj) {
- if (!obj || attachedBridge === obj) return
- attachedBridge = obj
- bridge.value = obj
- if (obj.get_app_version) {
- withResult(obj.get_app_version(), (value) => {
- if (value) {
- appVersion.value = String(value).trim()
- }
- })
- }
- bindCommBridgeSignals(obj)
- bindScriptBridgeSignals(obj)
- refreshPorts()
- refreshChannels()
- refreshProtocols()
- loadSettings()
-}
-
onMounted(() => {
- const timer = setInterval(() => {
- if (window.bridge) {
- attachBridge(window.bridge)
- clearInterval(timer)
- }
- }, 200)
+ startBridgeBootstrap()
scriptTimer = window.setInterval(() => {
if (scriptRunning.value && scriptStartMs.value) {
scriptElapsedMs.value = Date.now() - scriptStartMs.value
@@ -1171,6 +1147,7 @@ onBeforeUnmount(() => {
window.cancelAnimationFrame(logFlushHandle)
logFlushHandle = 0
}
+ disposeBridgeBootstrap()
disposeChannelSync()
if (scriptLogScrollRaf) {
window.cancelAnimationFrame(scriptLogScrollRaf)
@@ -1326,6 +1303,16 @@ const { loadSettings, saveSettings, chooseDslWorkspace } = useSettingsBridge({
buildSettingsPayload,
commitSettingsSnapshot,
})
+const { startBridgeBootstrap, disposeBridgeBootstrap } = useBridgeBootstrap({
+ bridge,
+ appVersion,
+ bindCommBridgeSignals,
+ bindScriptBridgeSignals,
+ refreshPorts,
+ refreshChannels,
+ refreshProtocols,
+ loadSettings,
+})
diff --git a/ui/frontend/src/composables/useBridgeBootstrap.test.ts b/ui/frontend/src/composables/useBridgeBootstrap.test.ts
new file mode 100644
index 0000000..1e14b5f
--- /dev/null
+++ b/ui/frontend/src/composables/useBridgeBootstrap.test.ts
@@ -0,0 +1,116 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { useBridgeBootstrap } from './useBridgeBootstrap'
+
+function createBootstrap() {
+ const bridge = ref(null)
+ const appVersion = ref('')
+ let commBindCount = 0
+ let scriptBindCount = 0
+ let portsCount = 0
+ let channelsCount = 0
+ let protocolsCount = 0
+ let settingsCount = 0
+
+ const bootstrap = useBridgeBootstrap({
+ bridge,
+ appVersion,
+ bindCommBridgeSignals: () => {
+ commBindCount += 1
+ },
+ bindScriptBridgeSignals: () => {
+ scriptBindCount += 1
+ },
+ refreshPorts: () => {
+ portsCount += 1
+ },
+ refreshChannels: () => {
+ channelsCount += 1
+ },
+ refreshProtocols: () => {
+ protocolsCount += 1
+ },
+ loadSettings: () => {
+ settingsCount += 1
+ },
+ })
+
+ return {
+ bridge,
+ appVersion,
+ bootstrap,
+ getCounters: () => ({
+ commBindCount,
+ scriptBindCount,
+ portsCount,
+ channelsCount,
+ protocolsCount,
+ settingsCount,
+ }),
+ }
+}
+
+describe('useBridgeBootstrap', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('attaches bridge once and runs bind/refresh pipeline', () => {
+ const { bridge, appVersion, bootstrap, getCounters } = createBootstrap()
+ const candidate = {
+ get_app_version: () => ' 1.2.3 ',
+ }
+
+ bootstrap.attachBridge(candidate)
+ expect(bridge.value).toEqual(candidate)
+ expect(appVersion.value).toBe('1.2.3')
+ expect(getCounters()).toEqual({
+ commBindCount: 1,
+ scriptBindCount: 1,
+ portsCount: 1,
+ channelsCount: 1,
+ protocolsCount: 1,
+ settingsCount: 1,
+ })
+
+ bootstrap.attachBridge(candidate)
+ expect(getCounters().commBindCount).toBe(1)
+ })
+
+ it('polls resolver and auto-stops after first attach', () => {
+ vi.useFakeTimers()
+ const { bridge, bootstrap, getCounters } = createBootstrap()
+ let calls = 0
+ const candidate = {}
+
+ bootstrap.startBridgeBootstrap(() => {
+ calls += 1
+ return calls >= 2 ? candidate : null
+ }, 100)
+
+ vi.advanceTimersByTime(100)
+ expect(bridge.value).toBe(null)
+ vi.advanceTimersByTime(100)
+ expect(bridge.value).toEqual(candidate)
+ expect(getCounters().commBindCount).toBe(1)
+
+ vi.advanceTimersByTime(300)
+ expect(calls).toBe(2)
+ })
+
+ it('disposes polling timer safely', () => {
+ vi.useFakeTimers()
+ const { bootstrap } = createBootstrap()
+ let calls = 0
+ bootstrap.startBridgeBootstrap(() => {
+ calls += 1
+ return null
+ }, 100)
+ vi.advanceTimersByTime(100)
+ expect(calls).toBe(1)
+ bootstrap.disposeBridgeBootstrap()
+ vi.advanceTimersByTime(300)
+ expect(calls).toBe(1)
+ })
+})
diff --git a/ui/frontend/src/composables/useBridgeBootstrap.ts b/ui/frontend/src/composables/useBridgeBootstrap.ts
new file mode 100644
index 0000000..73e0003
--- /dev/null
+++ b/ui/frontend/src/composables/useBridgeBootstrap.ts
@@ -0,0 +1,69 @@
+import type { Ref } from 'vue'
+
+type UseBridgeBootstrapOptions = {
+ bridge: Ref
+ appVersion: Ref
+ bindCommBridgeSignals: (obj: any) => void
+ bindScriptBridgeSignals: (obj: any) => void
+ refreshPorts: () => void
+ refreshChannels: () => void
+ refreshProtocols: () => void
+ loadSettings: () => void
+}
+
+function withResult(value: any, handler: (payload: any) => void) {
+ if (value && typeof value.then === 'function') {
+ value.then(handler)
+ return
+ }
+ handler(value)
+}
+
+export function useBridgeBootstrap(options: UseBridgeBootstrapOptions) {
+ let attachedBridge: any = null
+ let bridgePollTimer: number | null = null
+
+ function attachBridge(obj: any) {
+ if (!obj || attachedBridge === obj) return
+ attachedBridge = obj
+ options.bridge.value = obj
+ if (obj.get_app_version) {
+ withResult(obj.get_app_version(), (value) => {
+ if (value) {
+ options.appVersion.value = String(value).trim()
+ }
+ })
+ }
+ options.bindCommBridgeSignals(obj)
+ options.bindScriptBridgeSignals(obj)
+ options.refreshPorts()
+ options.refreshChannels()
+ options.refreshProtocols()
+ options.loadSettings()
+ }
+
+ function startBridgeBootstrap(resolveBridge: () => any = () => (window as any).bridge, intervalMs = 200) {
+ if (bridgePollTimer) return
+ bridgePollTimer = window.setInterval(() => {
+ const candidate = resolveBridge()
+ if (!candidate) return
+ attachBridge(candidate)
+ if (bridgePollTimer) {
+ window.clearInterval(bridgePollTimer)
+ bridgePollTimer = null
+ }
+ }, intervalMs)
+ }
+
+ function disposeBridgeBootstrap() {
+ if (!bridgePollTimer) return
+ window.clearInterval(bridgePollTimer)
+ bridgePollTimer = null
+ }
+
+ return {
+ attachBridge,
+ startBridgeBootstrap,
+ disposeBridgeBootstrap,
+ }
+}
From 65469b5b87d4652df47668f0dd0cc94abf3553f0 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:40:15 +0800
Subject: [PATCH 057/145] refactor(frontend): extract log buffer composable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- extract log buffer composable
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- extract log buffer 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 | 2 +-
ui/frontend/src/App.vue | 97 ++-------------
.../src/composables/useLogBuffers.test.ts | 113 ++++++++++++++++++
ui/frontend/src/composables/useLogBuffers.ts | 111 +++++++++++++++++
4 files changed, 238 insertions(+), 85 deletions(-)
create mode 100644 ui/frontend/src/composables/useLogBuffers.test.ts
create mode 100644 ui/frontend/src/composables/useLogBuffers.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index 5656225..d8d9d0a 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [~] F. App 逻辑下沉:已接管 channel 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 13e66ef..4c8ec98 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -40,6 +40,7 @@ import { useSettingsPersistence } from './composables/useSettingsPersistence'
import { useSettingsBridge } from './composables/useSettingsBridge'
import { useProtocolManager } from './composables/useProtocolManager'
import { useBridgeBootstrap } from './composables/useBridgeBootstrap'
+import { useLogBuffers } from './composables/useLogBuffers'
const bridge = ref(null)
const sidebarRef = ref(null)
@@ -57,16 +58,9 @@ const sendHex = ref('')
const commLogs = ref([])
const scriptLogs = ref([])
const commPaused = ref(false)
-const commLogBuffer = []
-const scriptLogBuffer = []
const MAX_COMM_LOGS = 200
const MAX_SCRIPT_LOGS = 200
const MAX_RENDER_LOGS = 120
-let logFlushHandle = 0
-let commLogSeq = 0
-let scriptLogSeq = 0
-let lastStatusText = ''
-let hasStatusActivity = false
const captureFrames = ref([])
const captureMeta = ref({
channel: '',
@@ -155,6 +149,16 @@ const channelTab = ref('all')
const uiRuntime = useUiRuntimeStore()
const uiModalOpen = ref(false)
const appVersion = ref('')
+const { scriptLogBuffer, hasStatusActivity, addCommLog, emitStatus, addScriptLog, clearCommLogs, toggleCommPaused, disposeLogBuffers } =
+ useLogBuffers({
+ commLogs,
+ scriptLogs,
+ commPaused,
+ scriptRunning,
+ scriptState,
+ maxCommLogs: MAX_COMM_LOGS,
+ maxScriptLogs: MAX_SCRIPT_LOGS,
+ })
const { noPorts, portOptionsList, applyPorts, selectPort: selectChannelPort } = useChannelState(ports, selectedPort)
const { resolveSerialPort } = useSerialInteraction()
@@ -436,7 +440,7 @@ const { bindCommBridgeSignals } = useCommBridgeSignals({
onConnectionInfo: (nextInfo) => {
connectionInfo.value = nextInfo
},
- shouldEmitDisconnected: (reason) => hasStatusActivity || Boolean(reason),
+ shouldEmitDisconnected: (reason) => hasStatusActivity.value || Boolean(reason),
})
const {
@@ -787,14 +791,6 @@ function parseBridgePayload(payload) {
}
}
-function scheduleLogFlush() {
- if (logFlushHandle) return
- logFlushHandle = window.requestAnimationFrame(() => {
- logFlushHandle = 0
- flushLogs()
- })
-}
-
function formatCaptureTime(ts) {
const date = new Date((ts || 0) * 1000)
const pad = (value, length = 2) => String(value).padStart(length, '0')
@@ -854,70 +850,6 @@ function ingestCaptureFrame(payload) {
}
}
-function flushLogs() {
- if (commLogBuffer.length) {
- const batch = commLogBuffer.splice(0, commLogBuffer.length)
- commLogs.value.push(...batch)
- if (commLogs.value.length > MAX_COMM_LOGS) {
- commLogs.value.splice(0, commLogs.value.length - MAX_COMM_LOGS)
- }
- }
- if (scriptLogBuffer.length) {
- scriptLogs.value.push(...scriptLogBuffer.splice(0, scriptLogBuffer.length))
- if (scriptLogs.value.length > MAX_SCRIPT_LOGS) {
- scriptLogs.value.splice(0, scriptLogs.value.length - MAX_SCRIPT_LOGS)
- }
- }
-}
-
-function addCommLog(kind, payload) {
- if (commPaused.value) return
- if (!payload || typeof payload !== 'object') {
- payload = { text: String(payload || ''), hex: '', ts: Date.now() / 1000 }
- } else if (!payload.text && !payload.hex) {
- payload = { text: JSON.stringify(payload), hex: '', ts: payload.ts || Date.now() / 1000 }
- }
- commLogBuffer.push({
- id: `c${commLogSeq++}`,
- kind,
- text: payload.text || '',
- hex: payload.hex || '',
- ts: payload.ts || Date.now() / 1000,
- })
- scheduleLogFlush()
-}
-
-function emitStatus(text, ts) {
- const message = String(text || '')
- if (!message || message === lastStatusText) return
- lastStatusText = message
- hasStatusActivity = true
- addCommLog('STATUS', { text: message, ts })
-}
-
-function addScriptLog(line) {
- const text = String(line || '')
- scriptLogBuffer.push({ id: `s${scriptLogSeq++}`, text })
- scheduleLogFlush()
- if (
- text.includes('Script finished') ||
- text.includes('Script stopped') ||
- text.toLowerCase().includes('[error]')
- ) {
- scriptRunning.value = false
- scriptState.value = 'idle'
- }
-}
-
-function clearCommLogs() {
- commLogs.value = []
- commLogBuffer.length = 0
-}
-
-function toggleCommPaused() {
- commPaused.value = !commPaused.value
-}
-
function formatCommLine(item) {
if (!item) return ''
const ts = item.ts ? formatTime(item.ts) : ''
@@ -1143,10 +1075,7 @@ onBeforeUnmount(() => {
window.clearTimeout(scriptVarTimer)
scriptVarTimer = null
}
- if (logFlushHandle) {
- window.cancelAnimationFrame(logFlushHandle)
- logFlushHandle = 0
- }
+ disposeLogBuffers()
disposeBridgeBootstrap()
disposeChannelSync()
if (scriptLogScrollRaf) {
diff --git a/ui/frontend/src/composables/useLogBuffers.test.ts b/ui/frontend/src/composables/useLogBuffers.test.ts
new file mode 100644
index 0000000..86761f0
--- /dev/null
+++ b/ui/frontend/src/composables/useLogBuffers.test.ts
@@ -0,0 +1,113 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { useLogBuffers } from './useLogBuffers'
+
+function createManager() {
+ const commLogs = ref([])
+ const scriptLogs = ref([])
+ const commPaused = ref(false)
+ const scriptRunning = ref(true)
+ const scriptState = ref('running')
+ const mgr = useLogBuffers({
+ commLogs,
+ scriptLogs,
+ commPaused,
+ scriptRunning,
+ scriptState,
+ maxCommLogs: 3,
+ maxScriptLogs: 2,
+ })
+ return { commLogs, scriptLogs, commPaused, scriptRunning, scriptState, mgr }
+}
+
+describe('useLogBuffers', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('buffers comm logs and flushes in animation frame', () => {
+ let rafCallback: FrameRequestCallback | null = null
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
+ rafCallback = cb
+ return 1
+ })
+ const { commLogs, mgr } = createManager()
+
+ mgr.addCommLog('RX', 'PING')
+ mgr.addCommLog('TX', { hex: 'AA BB', ts: 12 })
+ expect(commLogs.value).toHaveLength(0)
+
+ rafCallback?.(0)
+ expect(commLogs.value).toHaveLength(2)
+ expect(commLogs.value[0].text).toBe('PING')
+ expect(commLogs.value[1].hex).toBe('AA BB')
+ })
+
+ it('respects pause and clears comm logs', () => {
+ let rafCallback: FrameRequestCallback | null = null
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
+ rafCallback = cb
+ return 2
+ })
+ const { commLogs, commPaused, mgr } = createManager()
+ commPaused.value = true
+
+ mgr.addCommLog('RX', 'DROP')
+ rafCallback?.(0)
+ expect(commLogs.value).toHaveLength(0)
+
+ mgr.toggleCommPaused()
+ mgr.addCommLog('RX', 'KEEP')
+ rafCallback?.(0)
+ expect(commLogs.value).toHaveLength(1)
+
+ mgr.clearCommLogs()
+ expect(commLogs.value).toHaveLength(0)
+ })
+
+ it('deduplicates status text and marks status activity', () => {
+ let rafCallback: FrameRequestCallback | null = null
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
+ rafCallback = cb
+ return 3
+ })
+ const { commLogs, mgr } = createManager()
+
+ mgr.emitStatus('Connected: COM3', 1)
+ mgr.emitStatus('Connected: COM3', 2)
+ mgr.emitStatus('Error: timeout', 3)
+ rafCallback?.(0)
+
+ expect(mgr.hasStatusActivity.value).toBe(true)
+ expect(commLogs.value.filter((item) => item.kind === 'STATUS')).toHaveLength(2)
+ })
+
+ it('flushes script logs and handles terminal states', () => {
+ let rafCallback: FrameRequestCallback | null = null
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
+ rafCallback = cb
+ return 4
+ })
+ const { scriptLogs, scriptRunning, scriptState, mgr } = createManager()
+
+ mgr.addScriptLog('line1')
+ mgr.addScriptLog('Script finished')
+ rafCallback?.(0)
+
+ expect(scriptLogs.value).toHaveLength(2)
+ expect(scriptRunning.value).toBe(false)
+ expect(scriptState.value).toBe('idle')
+ })
+
+ it('disposes pending raf flush', () => {
+ vi.useFakeTimers()
+ const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame')
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 99)
+ const { mgr } = createManager()
+
+ mgr.addCommLog('RX', 'pending')
+ mgr.disposeLogBuffers()
+ expect(cancelSpy).toHaveBeenCalledWith(99)
+ })
+})
diff --git a/ui/frontend/src/composables/useLogBuffers.ts b/ui/frontend/src/composables/useLogBuffers.ts
new file mode 100644
index 0000000..7eea9d2
--- /dev/null
+++ b/ui/frontend/src/composables/useLogBuffers.ts
@@ -0,0 +1,111 @@
+import type { Ref } from 'vue'
+import { ref } from 'vue'
+
+type UseLogBuffersOptions = {
+ commLogs: Ref
+ scriptLogs: Ref
+ commPaused: Ref
+ scriptRunning: Ref
+ scriptState: Ref
+ maxCommLogs: number
+ maxScriptLogs: number
+}
+
+export function useLogBuffers(options: UseLogBuffersOptions) {
+ const commLogBuffer: any[] = []
+ const scriptLogBuffer: any[] = []
+ const hasStatusActivity = ref(false)
+ let logFlushHandle = 0
+ let commLogSeq = 0
+ let scriptLogSeq = 0
+ let lastStatusText = ''
+
+ function flushLogs() {
+ if (commLogBuffer.length) {
+ const batch = commLogBuffer.splice(0, commLogBuffer.length)
+ options.commLogs.value.push(...batch)
+ if (options.commLogs.value.length > options.maxCommLogs) {
+ options.commLogs.value.splice(0, options.commLogs.value.length - options.maxCommLogs)
+ }
+ }
+ if (scriptLogBuffer.length) {
+ options.scriptLogs.value.push(...scriptLogBuffer.splice(0, scriptLogBuffer.length))
+ if (options.scriptLogs.value.length > options.maxScriptLogs) {
+ options.scriptLogs.value.splice(0, options.scriptLogs.value.length - options.maxScriptLogs)
+ }
+ }
+ }
+
+ function scheduleLogFlush() {
+ if (logFlushHandle) return
+ logFlushHandle = window.requestAnimationFrame(() => {
+ logFlushHandle = 0
+ flushLogs()
+ })
+ }
+
+ function addCommLog(kind: string, payload: any) {
+ if (options.commPaused.value) return
+ if (!payload || typeof payload !== 'object') {
+ payload = { text: String(payload || ''), hex: '', ts: Date.now() / 1000 }
+ } else if (!payload.text && !payload.hex) {
+ payload = { text: JSON.stringify(payload), hex: '', ts: payload.ts || Date.now() / 1000 }
+ }
+ commLogBuffer.push({
+ id: `c${commLogSeq++}`,
+ kind,
+ text: payload.text || '',
+ hex: payload.hex || '',
+ ts: payload.ts || Date.now() / 1000,
+ })
+ scheduleLogFlush()
+ }
+
+ function emitStatus(text: string, ts: number) {
+ const message = String(text || '')
+ if (!message || message === lastStatusText) return
+ lastStatusText = message
+ hasStatusActivity.value = true
+ addCommLog('STATUS', { text: message, ts })
+ }
+
+ function addScriptLog(line: any) {
+ const text = String(line || '')
+ scriptLogBuffer.push({ id: `s${scriptLogSeq++}`, text })
+ scheduleLogFlush()
+ if (
+ text.includes('Script finished') ||
+ text.includes('Script stopped') ||
+ text.toLowerCase().includes('[error]')
+ ) {
+ options.scriptRunning.value = false
+ options.scriptState.value = 'idle'
+ }
+ }
+
+ function clearCommLogs() {
+ options.commLogs.value = []
+ commLogBuffer.length = 0
+ }
+
+ function toggleCommPaused() {
+ options.commPaused.value = !options.commPaused.value
+ }
+
+ function disposeLogBuffers() {
+ if (!logFlushHandle) return
+ window.cancelAnimationFrame(logFlushHandle)
+ logFlushHandle = 0
+ }
+
+ return {
+ scriptLogBuffer,
+ hasStatusActivity,
+ addCommLog,
+ emitStatus,
+ addScriptLog,
+ clearCommLogs,
+ toggleCommPaused,
+ disposeLogBuffers,
+ }
+}
From e462c60fc4976df5d99d085a95bda15b7dba14e6 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:42:38 +0800
Subject: [PATCH 058/145] refactor(frontend): unify async result helper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- unify async result helper
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- unify async result helper
- 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 | 9 +-------
.../src/composables/useBridgeBootstrap.ts | 9 +-------
ui/frontend/src/composables/useChannelSync.ts | 9 +-------
ui/frontend/src/utils/withResult.test.ts | 21 +++++++++++++++++++
ui/frontend/src/utils/withResult.ts | 7 +++++++
6 files changed, 32 insertions(+), 25 deletions(-)
create mode 100644 ui/frontend/src/utils/withResult.test.ts
create mode 100644 ui/frontend/src/utils/withResult.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index d8d9d0a..5213ebc 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [~] F. App 逻辑下沉:已接管 channel 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),统一异步结果处理到 utils/withResult 并去除 App 与 composable 内重复实现,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 4c8ec98..0def36d 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -22,6 +22,7 @@ import { useUiRuntimeStore } from './stores/uiRuntime'
import * as i18nCore from './i18n'
import { fallbackPorts, networkDefaults, serialDefaults, supportedBaudRates, uiDefaults } from './config/runtimeDefaults'
import { normalizeSerialPortName } from './utils/serialPort'
+import { withResult } from './utils/withResult'
import { useChannelState } from './composables/useChannelState'
import { useSerialInteraction } from './composables/useSerialInteraction'
import { useChannelConnectionActions } from './composables/useChannelConnectionActions'
@@ -1032,14 +1033,6 @@ function addCommBatch(batch) {
}
}
-function withResult(value, handler) {
- if (value && typeof value.then === 'function') {
- value.then(handler)
- } else {
- handler(value)
- }
-}
-
function refreshPorts() {
if (!bridge.value) return
withResult(bridge.value.list_ports(), (items) => {
diff --git a/ui/frontend/src/composables/useBridgeBootstrap.ts b/ui/frontend/src/composables/useBridgeBootstrap.ts
index 73e0003..552626a 100644
--- a/ui/frontend/src/composables/useBridgeBootstrap.ts
+++ b/ui/frontend/src/composables/useBridgeBootstrap.ts
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
+import { withResult } from '../utils/withResult'
type UseBridgeBootstrapOptions = {
bridge: Ref
@@ -11,14 +12,6 @@ type UseBridgeBootstrapOptions = {
loadSettings: () => void
}
-function withResult(value: any, handler: (payload: any) => void) {
- if (value && typeof value.then === 'function') {
- value.then(handler)
- return
- }
- handler(value)
-}
-
export function useBridgeBootstrap(options: UseBridgeBootstrapOptions) {
let attachedBridge: any = null
let bridgePollTimer: number | null = null
diff --git a/ui/frontend/src/composables/useChannelSync.ts b/ui/frontend/src/composables/useChannelSync.ts
index 57a86ad..24045f1 100644
--- a/ui/frontend/src/composables/useChannelSync.ts
+++ b/ui/frontend/src/composables/useChannelSync.ts
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
+import { withResult } from '../utils/withResult'
type ConnectionInfo = {
state: string
@@ -13,14 +14,6 @@ type UseChannelSyncOptions = {
emitStatus: (text: string, ts: number) => void
}
-function withResult(value: any, handler: (payload: any) => void) {
- if (value && typeof value.then === 'function') {
- value.then(handler)
- return
- }
- handler(value)
-}
-
export function useChannelSync(options: UseChannelSyncOptions) {
let channelRefreshTimer: number | null = null
let channelUpdateRaf = 0
diff --git a/ui/frontend/src/utils/withResult.test.ts b/ui/frontend/src/utils/withResult.test.ts
new file mode 100644
index 0000000..90fe9d7
--- /dev/null
+++ b/ui/frontend/src/utils/withResult.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { withResult } from './withResult'
+
+describe('withResult', () => {
+ it('handles plain values', () => {
+ let output = 0
+ withResult(42, (value) => {
+ output = value
+ })
+ expect(output).toBe(42)
+ })
+
+ it('handles promise values', async () => {
+ let output = ''
+ withResult(Promise.resolve('ok'), (value) => {
+ output = value
+ })
+ await Promise.resolve()
+ expect(output).toBe('ok')
+ })
+})
diff --git a/ui/frontend/src/utils/withResult.ts b/ui/frontend/src/utils/withResult.ts
new file mode 100644
index 0000000..85fe0b4
--- /dev/null
+++ b/ui/frontend/src/utils/withResult.ts
@@ -0,0 +1,7 @@
+export function withResult(value: T | Promise, handler: (payload: T) => void) {
+ if (value && typeof (value as any).then === 'function') {
+ ;(value as Promise).then(handler)
+ return
+ }
+ handler(value as T)
+}
From 1d24de15ae86bde51c24a70a747ce38934b35242 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:44:34 +0800
Subject: [PATCH 059/145] refactor(frontend): extract comm batch ingestor
composable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- extract comm batch ingestor composable
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- extract comm batch ingestor 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 | 2 +-
ui/frontend/src/App.vue | 42 ++--------------
.../composables/useCommBatchIngestor.test.ts | 48 ++++++++++++++++++
.../src/composables/useCommBatchIngestor.ts | 50 +++++++++++++++++++
4 files changed, 104 insertions(+), 38 deletions(-)
create mode 100644 ui/frontend/src/composables/useCommBatchIngestor.test.ts
create mode 100644 ui/frontend/src/composables/useCommBatchIngestor.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index 5213ebc..c8c6822 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),统一异步结果处理到 utils/withResult 并去除 App 与 composable 内重复实现,拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [~] F. App 逻辑下沉:已接管 channel 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),统一异步结果处理到 utils/withResult 并去除 App 与 composable 内重复实现,且已下沉通信批量解析入口(parseBridgePayload/addCommBatch),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 0def36d..0bc094d 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -29,6 +29,7 @@ import { useChannelConnectionActions } from './composables/useChannelConnectionA
import { useChannelSync } from './composables/useChannelSync'
import { useChannelDialog } from './composables/useChannelDialog'
import { usePayloadSender } from './composables/usePayloadSender'
+import { useCommBatchIngestor } from './composables/useCommBatchIngestor'
import { useYamlDocumentOps } from './composables/useYamlDocumentOps'
import { useScriptRunner } from './composables/useScriptRunner'
import { useScriptLogHelpers } from './composables/useScriptLogHelpers'
@@ -160,6 +161,10 @@ const { scriptLogBuffer, hasStatusActivity, addCommLog, emitStatus, addScriptLog
maxCommLogs: MAX_COMM_LOGS,
maxScriptLogs: MAX_SCRIPT_LOGS,
})
+const { parseBridgePayload, addCommBatch } = useCommBatchIngestor({
+ commPaused,
+ addCommLog,
+})
const { noPorts, portOptionsList, applyPorts, selectPort: selectChannelPort } = useChannelState(ports, selectedPort)
const { resolveSerialPort } = useSerialInteraction()
@@ -783,15 +788,6 @@ function formatPayload(item) {
return item.text || item.hex || ''
}
-function parseBridgePayload(payload) {
- if (typeof payload !== 'string') return payload
- try {
- return JSON.parse(payload)
- } catch (err) {
- return { text: String(payload) }
- }
-}
-
function formatCaptureTime(ts) {
const date = new Date((ts || 0) * 1000)
const pad = (value, length = 2) => String(value).padStart(length, '0')
@@ -1005,34 +1001,6 @@ function countQuickPayload(payload, mode) {
return value.length
}
-function addCommBatch(batch) {
- if (commPaused.value) return
- batch = parseBridgePayload(batch)
- if (!Array.isArray(batch)) return
- for (const item of batch) {
- if (!item) continue
- const kind = item.kind || 'RX'
- if (kind === 'FRAME') {
- addCommLog('FRAME', { text: JSON.stringify(item.payload), ts: item.ts })
- } else {
- let payload = item.payload || {}
- if (
- payload &&
- typeof payload === 'object' &&
- !payload.text &&
- !payload.hex &&
- (item.text || item.hex)
- ) {
- payload = { text: item.text || '', hex: item.hex || '', ts: item.ts || payload.ts }
- }
- if (payload && typeof payload === 'object' && !payload.text && !payload.hex) {
- payload = { text: JSON.stringify(item), ts: item.ts || payload.ts }
- }
- addCommLog(kind, payload)
- }
- }
-}
-
function refreshPorts() {
if (!bridge.value) return
withResult(bridge.value.list_ports(), (items) => {
diff --git a/ui/frontend/src/composables/useCommBatchIngestor.test.ts b/ui/frontend/src/composables/useCommBatchIngestor.test.ts
new file mode 100644
index 0000000..b93104b
--- /dev/null
+++ b/ui/frontend/src/composables/useCommBatchIngestor.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+import { useCommBatchIngestor } from './useCommBatchIngestor'
+
+describe('useCommBatchIngestor', () => {
+ it('parses bridge payload json and fallback text', () => {
+ const calls: Array<{ kind: string; payload: any }> = []
+ const mgr = useCommBatchIngestor({
+ commPaused: ref(false),
+ addCommLog: (kind, payload) => calls.push({ kind, payload }),
+ })
+
+ expect(mgr.parseBridgePayload('[{\"kind\":\"RX\"}]')).toEqual([{ kind: 'RX' }])
+ expect(mgr.parseBridgePayload('not-json')).toEqual({ text: 'not-json' })
+ })
+
+ it('ingests batch and normalizes payloads', () => {
+ const calls: Array<{ kind: string; payload: any }> = []
+ const mgr = useCommBatchIngestor({
+ commPaused: ref(false),
+ addCommLog: (kind, payload) => calls.push({ kind, payload }),
+ })
+
+ mgr.addCommBatch([
+ { kind: 'RX', text: 'A', ts: 1 },
+ { kind: 'FRAME', payload: { p: 1 }, ts: 2 },
+ { kind: 'TX', payload: { foo: 1 }, ts: 3 },
+ ])
+
+ expect(calls).toHaveLength(3)
+ expect(calls[0]).toEqual({ kind: 'RX', payload: { text: 'A', hex: '', ts: 1 } })
+ expect(calls[1].kind).toBe('FRAME')
+ expect(calls[1].payload.text).toBe(JSON.stringify({ p: 1 }))
+ expect(calls[2].payload.text).toBe(JSON.stringify({ kind: 'TX', payload: { foo: 1 }, ts: 3 }))
+ })
+
+ it('ignores batches when paused', () => {
+ let count = 0
+ const mgr = useCommBatchIngestor({
+ commPaused: ref(true),
+ addCommLog: () => {
+ count += 1
+ },
+ })
+ mgr.addCommBatch([{ kind: 'RX', text: 'A' }])
+ expect(count).toBe(0)
+ })
+})
diff --git a/ui/frontend/src/composables/useCommBatchIngestor.ts b/ui/frontend/src/composables/useCommBatchIngestor.ts
new file mode 100644
index 0000000..9dbcc69
--- /dev/null
+++ b/ui/frontend/src/composables/useCommBatchIngestor.ts
@@ -0,0 +1,50 @@
+import type { Ref } from 'vue'
+
+type UseCommBatchIngestorOptions = {
+ commPaused: Ref
+ addCommLog: (kind: string, payload: any) => void
+}
+
+export function useCommBatchIngestor(options: UseCommBatchIngestorOptions) {
+ function parseBridgePayload(payload: any) {
+ if (typeof payload !== 'string') return payload
+ try {
+ return JSON.parse(payload)
+ } catch (err) {
+ return { text: String(payload) }
+ }
+ }
+
+ function addCommBatch(batch: any) {
+ if (options.commPaused.value) return
+ batch = parseBridgePayload(batch)
+ if (!Array.isArray(batch)) return
+ for (const item of batch) {
+ if (!item) continue
+ const kind = item.kind || 'RX'
+ if (kind === 'FRAME') {
+ options.addCommLog('FRAME', { text: JSON.stringify(item.payload), ts: item.ts })
+ continue
+ }
+ let payload = item.payload || {}
+ if (
+ payload &&
+ typeof payload === 'object' &&
+ !payload.text &&
+ !payload.hex &&
+ (item.text || item.hex)
+ ) {
+ payload = { text: item.text || '', hex: item.hex || '', ts: item.ts || payload.ts }
+ }
+ if (payload && typeof payload === 'object' && !payload.text && !payload.hex) {
+ payload = { text: JSON.stringify(item), ts: item.ts || payload.ts }
+ }
+ options.addCommLog(kind, payload)
+ }
+ }
+
+ return {
+ parseBridgePayload,
+ addCommBatch,
+ }
+}
From 9b7d908bf370a9527af133c564de9dfd2794efec Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:47:38 +0800
Subject: [PATCH 060/145] refactor(frontend): extract comm log export
composable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- extract comm log export composable
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- extract comm log export 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 | 2 +-
ui/frontend/src/App.vue | 35 +++----------
.../src/composables/useCommLogExport.test.ts | 52 +++++++++++++++++++
.../src/composables/useCommLogExport.ts | 51 ++++++++++++++++++
4 files changed, 110 insertions(+), 30 deletions(-)
create mode 100644 ui/frontend/src/composables/useCommLogExport.test.ts
create mode 100644 ui/frontend/src/composables/useCommLogExport.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index c8c6822..e167119 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),统一异步结果处理到 utils/withResult 并去除 App 与 composable 内重复实现,且已下沉通信批量解析入口(parseBridgePayload/addCommBatch),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [~] F. App 逻辑下沉:已接管 channel 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),统一异步结果处理到 utils/withResult 并去除 App 与 composable 内重复实现,且已下沉通信批量解析入口(parseBridgePayload/addCommBatch)与通信日志格式化导出入口(formatPayload/exportCommLogs),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 0bc094d..2907a6a 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -30,6 +30,7 @@ import { useChannelSync } from './composables/useChannelSync'
import { useChannelDialog } from './composables/useChannelDialog'
import { usePayloadSender } from './composables/usePayloadSender'
import { useCommBatchIngestor } from './composables/useCommBatchIngestor'
+import { useCommLogExport } from './composables/useCommLogExport'
import { useYamlDocumentOps } from './composables/useYamlDocumentOps'
import { useScriptRunner } from './composables/useScriptRunner'
import { useScriptLogHelpers } from './composables/useScriptLogHelpers'
@@ -165,6 +166,11 @@ const { parseBridgePayload, addCommBatch } = useCommBatchIngestor({
commPaused,
addCommLog,
})
+const { formatPayload, exportCommLogs } = useCommLogExport({
+ displayMode,
+ commLogs,
+ formatTime,
+})
const { noPorts, portOptionsList, applyPorts, selectPort: selectChannelPort } = useChannelState(ports, selectedPort)
const { resolveSerialPort } = useSerialInteraction()
@@ -782,12 +788,6 @@ function toggleYamlCollapsed() {
yamlCollapsed.value = !yamlCollapsed.value
}
-function formatPayload(item) {
- if (!item) return ''
- if (displayMode.value === 'hex' && item.hex) return item.hex
- return item.text || item.hex || ''
-}
-
function formatCaptureTime(ts) {
const date = new Date((ts || 0) * 1000)
const pad = (value, length = 2) => String(value).padStart(length, '0')
@@ -847,29 +847,6 @@ function ingestCaptureFrame(payload) {
}
}
-function formatCommLine(item) {
- if (!item) return ''
- const ts = item.ts ? formatTime(item.ts) : ''
- const kind = item.kind || ''
- const payload = formatPayload(item).replace(/\r?\n/g, '\\n')
- return `${ts}\t${kind}\t${payload}`
-}
-
-function exportCommLogs() {
- const lines = commLogs.value.map((item) => formatCommLine(item)).filter(Boolean)
- const payload = lines.join('\n')
- const name = `io_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.log`
- const blob = new Blob([payload], { type: 'text/plain' })
- 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)
-}
-
function normalizeQuickCommands(list) {
if (!Array.isArray(list)) return quickCommands.value
const normalized = []
diff --git a/ui/frontend/src/composables/useCommLogExport.test.ts b/ui/frontend/src/composables/useCommLogExport.test.ts
new file mode 100644
index 0000000..76b6131
--- /dev/null
+++ b/ui/frontend/src/composables/useCommLogExport.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { useCommLogExport } from './useCommLogExport'
+
+describe('useCommLogExport', () => {
+ it('formats payload by display mode', () => {
+ const mgr = useCommLogExport({
+ displayMode: ref('ascii'),
+ commLogs: ref([]),
+ formatTime: () => '00:00:00.000',
+ })
+
+ expect(mgr.formatPayload({ text: 'ABC', hex: '41 42 43' })).toBe('ABC')
+
+ const mgrHex = useCommLogExport({
+ displayMode: ref('hex'),
+ commLogs: ref([]),
+ formatTime: () => '00:00:00.000',
+ })
+ expect(mgrHex.formatPayload({ text: 'ABC', hex: '41 42 43' })).toBe('41 42 43')
+ })
+
+ it('exports logs through object url and anchor click', async () => {
+ let capturedBlob: Blob | null = null
+ let revokedUrl = ''
+ const clickSpy = vi.fn()
+ const anchor = document.createElement('a')
+ anchor.click = clickSpy
+
+ const mgr = useCommLogExport({
+ displayMode: ref('ascii'),
+ commLogs: ref([{ ts: 1, kind: 'RX', text: 'A\nB' }]),
+ formatTime: () => '10:00:00.000',
+ createObjectUrl: (blob) => {
+ capturedBlob = blob
+ return 'blob:test'
+ },
+ revokeObjectUrl: (url) => {
+ revokedUrl = url
+ },
+ createAnchor: () => anchor,
+ })
+
+ mgr.exportCommLogs()
+
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ expect(anchor.download.startsWith('io_logs_')).toBe(true)
+ expect(revokedUrl).toBe('blob:test')
+ expect(capturedBlob).toBeTruthy()
+ expect(await capturedBlob!.text()).toContain('10:00:00.000\tRX\tA\\nB')
+ })
+})
diff --git a/ui/frontend/src/composables/useCommLogExport.ts b/ui/frontend/src/composables/useCommLogExport.ts
new file mode 100644
index 0000000..0f454d4
--- /dev/null
+++ b/ui/frontend/src/composables/useCommLogExport.ts
@@ -0,0 +1,51 @@
+import type { Ref } from 'vue'
+
+type UseCommLogExportOptions = {
+ displayMode: Ref
+ commLogs: Ref
+ formatTime: (ts: number) => string
+ createObjectUrl?: (blob: Blob) => string
+ revokeObjectUrl?: (url: string) => void
+ createAnchor?: () => HTMLAnchorElement
+}
+
+export function useCommLogExport(options: UseCommLogExportOptions) {
+ const createObjectUrl = options.createObjectUrl || ((blob: Blob) => URL.createObjectURL(blob))
+ const revokeObjectUrl = options.revokeObjectUrl || ((url: string) => URL.revokeObjectURL(url))
+ const createAnchor = options.createAnchor || (() => document.createElement('a'))
+
+ function formatPayload(item: any) {
+ if (!item) return ''
+ if (options.displayMode.value === 'hex' && item.hex) return item.hex
+ return item.text || item.hex || ''
+ }
+
+ function formatCommLine(item: any) {
+ if (!item) return ''
+ const ts = item.ts ? options.formatTime(item.ts) : ''
+ const kind = item.kind || ''
+ const payload = formatPayload(item).replace(/\r?\n/g, '\\n')
+ return `${ts}\t${kind}\t${payload}`
+ }
+
+ function exportCommLogs() {
+ const lines = options.commLogs.value.map((item) => formatCommLine(item)).filter(Boolean)
+ const payload = lines.join('\n')
+ const name = `io_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.log`
+ const blob = new Blob([payload], { type: 'text/plain' })
+ const url = createObjectUrl(blob)
+ const link = createAnchor()
+ link.href = url
+ link.download = name
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ revokeObjectUrl(url)
+ }
+
+ return {
+ formatPayload,
+ formatCommLine,
+ exportCommLogs,
+ }
+}
From 7fd3349712afb0d5fe2a6066901e8f1eeac33d61 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 18:59:19 +0800
Subject: [PATCH 061/145] refactor(frontend): complete app logic extraction for
quick and capture flows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- complete app logic extraction for quick and capture flows
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- complete app logic extraction for quick and capture flows
- 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 | 222 +++---------------
.../src/composables/useCaptureFrames.test.ts | 51 ++++
.../src/composables/useCaptureFrames.ts | 75 ++++++
.../src/composables/useQuickCommands.test.ts | 80 +++++++
.../src/composables/useQuickCommands.ts | 179 ++++++++++++++
6 files changed, 418 insertions(+), 191 deletions(-)
create mode 100644 ui/frontend/src/composables/useCaptureFrames.test.ts
create mode 100644 ui/frontend/src/composables/useCaptureFrames.ts
create mode 100644 ui/frontend/src/composables/useQuickCommands.test.ts
create mode 100644 ui/frontend/src/composables/useQuickCommands.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index e167119..f64e44e 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -4,7 +4,7 @@
- [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 端口处理与连接动作入口(串口/TCP/断开)及通道同步刷新(set/schedule/refresh/dispose),并下沉 bridge 挂载轮询与初始化流程(attach/start/dispose)及日志缓冲刷新/状态发射(comm+script buffer, emitStatus, dispose),统一异步结果处理到 utils/withResult 并去除 App 与 composable 内重复实现,且已下沉通信批量解析入口(parseBridgePayload/addCommBatch)与通信日志格式化导出入口(formatPayload/exportCommLogs),拆分协议页/ChannelDialog/UIYamlPreview 区块,并下沉协议管理/ChannelDialog/发送/YAML文档操作/脚本运行控制/YAML搜索/脚本日志辅助/脚本桥接事件/通信桥接事件/窗口控制逻辑到 composables
+- [x] F. App 逻辑下沉:已完成 channel 端口与连接动作、通道同步刷新、bridge 挂载轮询初始化、日志缓冲刷新与状态发射、异步结果处理统一(utils/withResult)、通信批量解析、通信日志格式化导出、抓包帧映射与接入、快捷指令管理(增删改/弹窗/归一化/计数)等逻辑下沉到 composables,App.vue 主要保留页面编排与绑定
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 2907a6a..14d47a6 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -31,6 +31,8 @@ import { useChannelDialog } from './composables/useChannelDialog'
import { usePayloadSender } from './composables/usePayloadSender'
import { useCommBatchIngestor } from './composables/useCommBatchIngestor'
import { useCommLogExport } from './composables/useCommLogExport'
+import { useCaptureFrames } from './composables/useCaptureFrames'
+import { useQuickCommands } from './composables/useQuickCommands'
import { useYamlDocumentOps } from './composables/useYamlDocumentOps'
import { useScriptRunner } from './composables/useScriptRunner'
import { useScriptLogHelpers } from './composables/useScriptLogHelpers'
@@ -171,6 +173,11 @@ const { formatPayload, exportCommLogs } = useCommLogExport({
commLogs,
formatTime,
})
+const { ingestCaptureFrame } = useCaptureFrames({
+ captureFrames,
+ captureMeta,
+ maxCaptureFrames: MAX_CAPTURE_FRAMES,
+})
const { noPorts, portOptionsList, applyPorts, selectPort: selectChannelPort } = useChannelState(ports, selectedPort)
const { resolveSerialPort } = useSerialInteraction()
@@ -193,6 +200,31 @@ const quickDraft = ref({
appendCR: true,
appendLF: true,
})
+const {
+ normalizeQuickCommands,
+ openQuickDeleteDialog,
+ closeQuickDeleteDialog,
+ confirmQuickDelete,
+ openQuickCommandDialog,
+ closeQuickCommandDialog,
+ saveQuickCommand,
+ addQuickCommand,
+ editQuickCommand,
+ removeQuickCommand,
+ countQuickPayload,
+} = useQuickCommands({
+ quickCommands,
+ quickDialogOpen,
+ quickDialogMode,
+ quickEditingId,
+ quickDeleteOpen,
+ quickDeleting,
+ quickDraft,
+ sendMode,
+ appendCR,
+ appendLF,
+ tr,
+})
const channels = ref([])
const channelCards = computed(() => {
@@ -788,196 +820,6 @@ function toggleYamlCollapsed() {
yamlCollapsed.value = !yamlCollapsed.value
}
-function formatCaptureTime(ts) {
- const date = new Date((ts || 0) * 1000)
- const pad = (value, length = 2) => String(value).padStart(length, '0')
- return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(
- date.getMilliseconds(),
- 3
- )}`
-}
-
-function mapCaptureFrame(payload) {
- if (!payload || typeof payload !== 'object') return null
- const protocol = payload.protocol || {}
- const unknown = Boolean(protocol.unknown)
- const hasErrors = Array.isArray(payload.errors) && payload.errors.length > 0
- const direction = payload.direction || 'RX'
- const tone = unknown || hasErrors ? 'red' : direction === 'TX' ? 'blue' : 'green'
- return {
- id: payload.id || `cap-${Date.now()}`,
- direction,
- time: formatCaptureTime(payload.timestamp || Date.now() / 1000),
- size: payload.length || 0,
- note: payload.raw_hex || '',
- summary: payload.summary || '',
- summaryText: payload.summary || '',
- tone,
- warn: unknown || hasErrors,
- channel: payload.channel || '',
- baud: payload.baud || '',
- protocolLabel: protocol.name || (unknown ? 'Unknown' : ''),
- protocolType: unknown
- ? 'unknown'
- : (protocol.name || '').toLowerCase().includes('modbus')
- ? 'modbus'
- : 'custom',
- protocolTooltip: protocol.name ? `${protocol.name}${protocol.version ? ` ${protocol.version}` : ''}` : '',
- hexDump: payload.hex_dump || null,
- tree: payload.tree || [],
- }
-}
-
-function ingestCaptureFrame(payload) {
- const frame = mapCaptureFrame(payload)
- if (!frame) return
- captureFrames.value.push(frame)
- if (captureFrames.value.length > MAX_CAPTURE_FRAMES) {
- captureFrames.value.splice(0, captureFrames.value.length - MAX_CAPTURE_FRAMES)
- }
- captureMeta.value.totalFrames += 1
- captureMeta.value.rangeEnd = captureMeta.value.totalFrames
- captureMeta.value.rangeStart = Math.max(1, captureMeta.value.totalFrames - captureFrames.value.length + 1)
- captureMeta.value.bufferUsed = Math.min(
- 100,
- Math.round((captureFrames.value.length / MAX_CAPTURE_FRAMES) * 100)
- )
- if (payload && payload.channel) {
- captureMeta.value.channel = payload.channel
- }
-}
-
-function normalizeQuickCommands(list) {
- if (!Array.isArray(list)) return quickCommands.value
- const normalized = []
- list.forEach((item, index) => {
- if (!item) return
- if (typeof item === 'string') {
- normalized.push({
- id: `qc_${index}`,
- name: item,
- payload: item,
- mode: 'text',
- appendCR: true,
- appendLF: true,
- })
- return
- }
- const name = String(item.name || item.payload || '').trim()
- const payload = String(item.payload || item.name || '').trim()
- if (!name || !payload) return
- normalized.push({
- id: item.id || `qc_${index}`,
- name,
- payload,
- mode: item.mode === 'hex' ? 'hex' : 'text',
- appendCR: item.appendCR ?? true,
- appendLF: item.appendLF ?? true,
- })
- })
- return normalized.length ? normalized : quickCommands.value
-}
-
-function addQuickCommand() {
- openQuickCommandDialog()
-}
-
-function editQuickCommand(cmd) {
- openQuickCommandDialog(cmd)
-}
-
-function removeQuickCommand(cmd) {
- openQuickDeleteDialog(cmd)
-}
-
-function openQuickDeleteDialog(cmd) {
- if (!cmd) return
- quickDeleting.value = cmd
- quickDeleteOpen.value = true
-}
-
-function closeQuickDeleteDialog() {
- quickDeleteOpen.value = false
- quickDeleting.value = null
-}
-
-function confirmQuickDelete() {
- if (!quickDeleting.value) return
- quickCommands.value = quickCommands.value.filter((item) => item.id !== quickDeleting.value.id)
- closeQuickDeleteDialog()
-}
-
-function openQuickCommandDialog(cmd) {
- if (cmd) {
- quickDialogMode.value = 'edit'
- quickEditingId.value = cmd.id
- quickDraft.value = {
- name: cmd.name || '',
- payload: cmd.payload || cmd.name || '',
- mode: cmd.mode === 'hex' ? 'hex' : 'text',
- appendCR: cmd.appendCR ?? true,
- appendLF: cmd.appendLF ?? true,
- }
- } else {
- quickDialogMode.value = 'create'
- quickEditingId.value = ''
- quickDraft.value = {
- name: '',
- payload: '',
- mode: sendMode.value === 'hex' ? 'hex' : 'text',
- appendCR: appendCR.value,
- appendLF: appendLF.value,
- }
- }
- quickDialogOpen.value = true
-}
-
-function closeQuickCommandDialog() {
- quickDialogOpen.value = false
-}
-
-function saveQuickCommand() {
- const name = String(quickDraft.value.name || '').trim()
- const payload = String(quickDraft.value.payload || '').trim()
- if (!name || !payload) {
- window.alert(tr('请输入指令名称和内容'))
- return
- }
- const record = {
- name,
- payload: quickDraft.value.payload,
- mode: quickDraft.value.mode === 'hex' ? 'hex' : 'text',
- appendCR: quickDraft.value.appendCR ?? true,
- appendLF: quickDraft.value.appendLF ?? true,
- }
- if (quickDialogMode.value === 'edit' && quickEditingId.value) {
- const target = quickCommands.value.find((item) => item.id === quickEditingId.value)
- if (target) {
- Object.assign(target, record)
- } else {
- quickCommands.value.push({
- id: quickEditingId.value,
- ...record,
- })
- }
- } else {
- quickCommands.value.push({
- id: `qc_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`,
- ...record,
- })
- }
- closeQuickCommandDialog()
-}
-
-function countQuickPayload(payload, mode) {
- const value = String(payload || '')
- if (mode === 'hex') {
- const cleaned = value.replace(/[^0-9a-fA-F]/g, '')
- return Math.floor(cleaned.length / 2)
- }
- return value.length
-}
-
function refreshPorts() {
if (!bridge.value) return
withResult(bridge.value.list_ports(), (items) => {
diff --git a/ui/frontend/src/composables/useCaptureFrames.test.ts b/ui/frontend/src/composables/useCaptureFrames.test.ts
new file mode 100644
index 0000000..60e29c6
--- /dev/null
+++ b/ui/frontend/src/composables/useCaptureFrames.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+import { formatCaptureTime, mapCaptureFrame, useCaptureFrames } from './useCaptureFrames'
+
+describe('useCaptureFrames', () => {
+ it('formats capture time and maps frame payload', () => {
+ const time = formatCaptureTime(1)
+ expect(time).toMatch(/^\d{2}:\d{2}:\d{2}\.\d{3}$/)
+
+ const mapped = mapCaptureFrame({
+ id: 'f1',
+ direction: 'TX',
+ timestamp: 1,
+ length: 4,
+ raw_hex: 'AA BB',
+ summary: 'sum',
+ channel: 'COM3',
+ protocol: { name: 'modbus', version: '1.0' },
+ tree: [{ k: 1 }],
+ })
+ expect(mapped?.id).toBe('f1')
+ expect(mapped?.tone).toBe('blue')
+ expect(mapped?.protocolType).toBe('modbus')
+ })
+
+ it('ingests frame list and caps by max size', () => {
+ const captureFrames = ref([])
+ const captureMeta = ref({
+ channel: '',
+ engine: 'x',
+ bufferUsed: 0,
+ rangeStart: 0,
+ rangeEnd: 0,
+ totalFrames: 0,
+ page: 1,
+ pageCount: 1,
+ })
+ const mgr = useCaptureFrames({ captureFrames, captureMeta, maxCaptureFrames: 2 })
+
+ mgr.ingestCaptureFrame({ id: 'a', timestamp: 1, summary: 'A', channel: 'COM1' })
+ mgr.ingestCaptureFrame({ id: 'b', timestamp: 2, summary: 'B', channel: 'COM1' })
+ mgr.ingestCaptureFrame({ id: 'c', timestamp: 3, summary: 'C', channel: 'COM2' })
+
+ expect(captureFrames.value).toHaveLength(2)
+ expect(captureFrames.value[0].id).toBe('b')
+ expect(captureMeta.value.totalFrames).toBe(3)
+ expect(captureMeta.value.rangeStart).toBe(2)
+ expect(captureMeta.value.rangeEnd).toBe(3)
+ expect(captureMeta.value.channel).toBe('COM2')
+ })
+})
diff --git a/ui/frontend/src/composables/useCaptureFrames.ts b/ui/frontend/src/composables/useCaptureFrames.ts
new file mode 100644
index 0000000..f656e90
--- /dev/null
+++ b/ui/frontend/src/composables/useCaptureFrames.ts
@@ -0,0 +1,75 @@
+import type { Ref } from 'vue'
+
+type UseCaptureFramesOptions = {
+ captureFrames: Ref
+ captureMeta: Ref
+ maxCaptureFrames: number
+}
+
+export function formatCaptureTime(ts: number) {
+ const date = new Date((ts || 0) * 1000)
+ const pad = (value: number, length = 2) => String(value).padStart(length, '0')
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(
+ date.getMilliseconds(),
+ 3
+ )}`
+}
+
+export function mapCaptureFrame(payload: any) {
+ if (!payload || typeof payload !== 'object') return null
+ const protocol = payload.protocol || {}
+ const unknown = Boolean(protocol.unknown)
+ const hasErrors = Array.isArray(payload.errors) && payload.errors.length > 0
+ const direction = payload.direction || 'RX'
+ const tone = unknown || hasErrors ? 'red' : direction === 'TX' ? 'blue' : 'green'
+ return {
+ id: payload.id || `cap-${Date.now()}`,
+ direction,
+ time: formatCaptureTime(payload.timestamp || Date.now() / 1000),
+ size: payload.length || 0,
+ note: payload.raw_hex || '',
+ summary: payload.summary || '',
+ summaryText: payload.summary || '',
+ tone,
+ warn: unknown || hasErrors,
+ channel: payload.channel || '',
+ baud: payload.baud || '',
+ protocolLabel: protocol.name || (unknown ? 'Unknown' : ''),
+ protocolType: unknown
+ ? 'unknown'
+ : (protocol.name || '').toLowerCase().includes('modbus')
+ ? 'modbus'
+ : 'custom',
+ protocolTooltip: protocol.name ? `${protocol.name}${protocol.version ? ` ${protocol.version}` : ''}` : '',
+ hexDump: payload.hex_dump || null,
+ tree: payload.tree || [],
+ }
+}
+
+export function useCaptureFrames(options: UseCaptureFramesOptions) {
+ function ingestCaptureFrame(payload: any) {
+ const frame = mapCaptureFrame(payload)
+ if (!frame) return
+ options.captureFrames.value.push(frame)
+ if (options.captureFrames.value.length > options.maxCaptureFrames) {
+ options.captureFrames.value.splice(0, options.captureFrames.value.length - options.maxCaptureFrames)
+ }
+ options.captureMeta.value.totalFrames += 1
+ options.captureMeta.value.rangeEnd = options.captureMeta.value.totalFrames
+ options.captureMeta.value.rangeStart = Math.max(
+ 1,
+ options.captureMeta.value.totalFrames - options.captureFrames.value.length + 1
+ )
+ options.captureMeta.value.bufferUsed = Math.min(
+ 100,
+ Math.round((options.captureFrames.value.length / options.maxCaptureFrames) * 100)
+ )
+ if (payload && payload.channel) {
+ options.captureMeta.value.channel = payload.channel
+ }
+ }
+
+ return {
+ ingestCaptureFrame,
+ }
+}
diff --git a/ui/frontend/src/composables/useQuickCommands.test.ts b/ui/frontend/src/composables/useQuickCommands.test.ts
new file mode 100644
index 0000000..2c6b4e9
--- /dev/null
+++ b/ui/frontend/src/composables/useQuickCommands.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { countQuickPayload, useQuickCommands } from './useQuickCommands'
+
+function createManager() {
+ const quickCommands = ref([{ id: 'q1', name: 'AT', payload: 'AT', mode: 'text', appendCR: true, appendLF: true }])
+ const quickDialogOpen = ref(false)
+ const quickDialogMode = ref('create')
+ const quickEditingId = ref('')
+ const quickDeleteOpen = ref(false)
+ const quickDeleting = ref(null)
+ const quickDraft = ref({ name: '', payload: '', mode: 'text', appendCR: true, appendLF: true })
+ const sendMode = ref('text')
+ const appendCR = ref(true)
+ const appendLF = ref(false)
+ const alertSpy = vi.fn()
+ const manager = useQuickCommands({
+ quickCommands,
+ quickDialogOpen,
+ quickDialogMode,
+ quickEditingId,
+ quickDeleteOpen,
+ quickDeleting,
+ quickDraft,
+ sendMode,
+ appendCR,
+ appendLF,
+ tr: (text) => text,
+ alert: alertSpy,
+ generateId: () => 'fixed-id',
+ })
+ return { quickCommands, quickDialogOpen, quickDialogMode, quickEditingId, quickDeleteOpen, quickDeleting, quickDraft, alertSpy, manager }
+}
+
+describe('useQuickCommands', () => {
+ it('normalizes quick commands list', () => {
+ const { quickCommands, manager } = createManager()
+ const result = manager.normalizeQuickCommands(['PING', { name: 'HEX', payload: 'AA', mode: 'hex' }, null])
+ expect(result).toHaveLength(2)
+ expect(result[0].name).toBe('PING')
+ expect(result[1].mode).toBe('hex')
+ expect(manager.normalizeQuickCommands(null)).toBe(quickCommands.value)
+ })
+
+ it('opens and saves create/edit dialogs', () => {
+ const { quickCommands, quickDialogMode, quickDialogOpen, quickDraft, manager } = createManager()
+ manager.openQuickCommandDialog()
+ expect(quickDialogOpen.value).toBe(true)
+ expect(quickDialogMode.value).toBe('create')
+
+ quickDraft.value = { name: 'CMD', payload: 'CMD', mode: 'text', appendCR: true, appendLF: true }
+ manager.saveQuickCommand()
+ expect(quickCommands.value.some((item) => item.id === 'fixed-id')).toBe(true)
+
+ manager.openQuickCommandDialog(quickCommands.value[0])
+ quickDraft.value = { name: 'AT2', payload: 'AT2', mode: 'text', appendCR: true, appendLF: true }
+ manager.saveQuickCommand()
+ expect(quickCommands.value[0].name).toBe('AT2')
+ })
+
+ it('deletes command via confirm flow', () => {
+ const { quickCommands, quickDeleteOpen, manager } = createManager()
+ manager.openQuickDeleteDialog(quickCommands.value[0])
+ expect(quickDeleteOpen.value).toBe(true)
+ manager.confirmQuickDelete()
+ expect(quickCommands.value).toHaveLength(0)
+ })
+
+ it('alerts when save payload/name is empty', () => {
+ const { alertSpy, quickDraft, manager } = createManager()
+ quickDraft.value = { name: '', payload: '', mode: 'text', appendCR: true, appendLF: true }
+ manager.saveQuickCommand()
+ expect(alertSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('counts quick payload size', () => {
+ expect(countQuickPayload('AA BB 0D', 'hex')).toBe(3)
+ expect(countQuickPayload('HELLO', 'text')).toBe(5)
+ })
+})
diff --git a/ui/frontend/src/composables/useQuickCommands.ts b/ui/frontend/src/composables/useQuickCommands.ts
new file mode 100644
index 0000000..b71e123
--- /dev/null
+++ b/ui/frontend/src/composables/useQuickCommands.ts
@@ -0,0 +1,179 @@
+import type { Ref } from 'vue'
+
+type QuickCommand = {
+ id: string
+ name: string
+ payload: string
+ mode: string
+ appendCR: boolean
+ appendLF: boolean
+}
+
+type UseQuickCommandsOptions = {
+ quickCommands: Ref
+ quickDialogOpen: Ref
+ quickDialogMode: Ref
+ quickEditingId: Ref
+ quickDeleteOpen: Ref
+ quickDeleting: Ref
+ quickDraft: Ref
+ sendMode: Ref
+ appendCR: Ref
+ appendLF: Ref
+ tr: (text: string) => string
+ alert?: (message: string) => void
+ generateId?: () => string
+}
+
+const defaultAlert = (message: string) => window.alert(message)
+const defaultId = () => `qc_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`
+
+export function countQuickPayload(payload: string, mode: string) {
+ const value = String(payload || '')
+ if (mode === 'hex') {
+ const cleaned = value.replace(/[^0-9a-fA-F]/g, '')
+ return Math.floor(cleaned.length / 2)
+ }
+ return value.length
+}
+
+export function useQuickCommands(options: UseQuickCommandsOptions) {
+ const showAlert = options.alert || defaultAlert
+ const createId = options.generateId || defaultId
+
+ function normalizeQuickCommands(list: any) {
+ if (!Array.isArray(list)) return options.quickCommands.value
+ const normalized: QuickCommand[] = []
+ list.forEach((item, index) => {
+ if (!item) return
+ if (typeof item === 'string') {
+ normalized.push({
+ id: `qc_${index}`,
+ name: item,
+ payload: item,
+ mode: 'text',
+ appendCR: true,
+ appendLF: true,
+ })
+ return
+ }
+ const name = String(item.name || item.payload || '').trim()
+ const payload = String(item.payload || item.name || '').trim()
+ if (!name || !payload) return
+ normalized.push({
+ id: item.id || `qc_${index}`,
+ name,
+ payload,
+ mode: item.mode === 'hex' ? 'hex' : 'text',
+ appendCR: item.appendCR ?? true,
+ appendLF: item.appendLF ?? true,
+ })
+ })
+ return normalized.length ? normalized : options.quickCommands.value
+ }
+
+ function openQuickDeleteDialog(cmd: any) {
+ if (!cmd) return
+ options.quickDeleting.value = cmd
+ options.quickDeleteOpen.value = true
+ }
+
+ function closeQuickDeleteDialog() {
+ options.quickDeleteOpen.value = false
+ options.quickDeleting.value = null
+ }
+
+ function confirmQuickDelete() {
+ if (!options.quickDeleting.value) return
+ options.quickCommands.value = options.quickCommands.value.filter((item) => item.id !== options.quickDeleting.value.id)
+ closeQuickDeleteDialog()
+ }
+
+ function openQuickCommandDialog(cmd?: any) {
+ if (cmd) {
+ options.quickDialogMode.value = 'edit'
+ options.quickEditingId.value = cmd.id
+ options.quickDraft.value = {
+ name: cmd.name || '',
+ payload: cmd.payload || cmd.name || '',
+ mode: cmd.mode === 'hex' ? 'hex' : 'text',
+ appendCR: cmd.appendCR ?? true,
+ appendLF: cmd.appendLF ?? true,
+ }
+ } else {
+ options.quickDialogMode.value = 'create'
+ options.quickEditingId.value = ''
+ options.quickDraft.value = {
+ name: '',
+ payload: '',
+ mode: options.sendMode.value === 'hex' ? 'hex' : 'text',
+ appendCR: options.appendCR.value,
+ appendLF: options.appendLF.value,
+ }
+ }
+ options.quickDialogOpen.value = true
+ }
+
+ function closeQuickCommandDialog() {
+ options.quickDialogOpen.value = false
+ }
+
+ function saveQuickCommand() {
+ const name = String(options.quickDraft.value.name || '').trim()
+ const payload = String(options.quickDraft.value.payload || '').trim()
+ if (!name || !payload) {
+ showAlert(options.tr('请输入指令名称和内容'))
+ return
+ }
+ const record = {
+ name,
+ payload: options.quickDraft.value.payload,
+ mode: options.quickDraft.value.mode === 'hex' ? 'hex' : 'text',
+ appendCR: options.quickDraft.value.appendCR ?? true,
+ appendLF: options.quickDraft.value.appendLF ?? true,
+ }
+ if (options.quickDialogMode.value === 'edit' && options.quickEditingId.value) {
+ const target = options.quickCommands.value.find((item) => item.id === options.quickEditingId.value)
+ if (target) {
+ Object.assign(target, record)
+ } else {
+ options.quickCommands.value.push({
+ id: options.quickEditingId.value,
+ ...record,
+ })
+ }
+ } else {
+ options.quickCommands.value.push({
+ id: createId(),
+ ...record,
+ })
+ }
+ closeQuickCommandDialog()
+ }
+
+ function addQuickCommand() {
+ openQuickCommandDialog()
+ }
+
+ function editQuickCommand(cmd: any) {
+ openQuickCommandDialog(cmd)
+ }
+
+ function removeQuickCommand(cmd: any) {
+ openQuickDeleteDialog(cmd)
+ }
+
+ return {
+ normalizeQuickCommands,
+ openQuickDeleteDialog,
+ closeQuickDeleteDialog,
+ confirmQuickDelete,
+ openQuickCommandDialog,
+ closeQuickCommandDialog,
+ saveQuickCommand,
+ addQuickCommand,
+ editQuickCommand,
+ removeQuickCommand,
+ countQuickPayload,
+ }
+}
From a4d90d6a658f6b67a1be08af2ec67a63e07dff33 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 19:06:21 +0800
Subject: [PATCH 062/145] fix(frontend): proxy capture interactions and
baseline audit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- proxy capture interactions and baseline audit
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- proxy capture interactions and baseline audit
- 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 +-
docs/FRONTEND_BASELINE_AUDIT.md | 58 +++++++++
docs/FRONTEND_INTERACTION_TEST_MATRIX.md | 4 +
.../src/components/ProxyMonitorView.vue | 123 ++++++++++++++++--
.../proxy/ProxyCaptureDetails.test.ts | 69 ++++++++++
.../components/proxy/ProxyCaptureDetails.vue | 32 ++++-
.../proxy/ProxyCaptureFooter.test.ts | 60 +++++++++
.../components/proxy/ProxyCaptureFooter.vue | 24 +++-
.../proxy/ProxyCaptureToolbar.test.ts | 64 +++++++++
.../components/proxy/ProxyCaptureToolbar.vue | 33 ++++-
10 files changed, 443 insertions(+), 26 deletions(-)
create mode 100644 docs/FRONTEND_BASELINE_AUDIT.md
create mode 100644 ui/frontend/src/components/proxy/ProxyCaptureDetails.test.ts
create mode 100644 ui/frontend/src/components/proxy/ProxyCaptureFooter.test.ts
create mode 100644 ui/frontend/src/components/proxy/ProxyCaptureToolbar.test.ts
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index f64e44e..5cfa099 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -1,5 +1,5 @@
# Plan: ProtoFlow 前端交互与性能统一治理(全站)
-- [~] A. 基线盘点:写死清单、交互问题清单、桌面壳性能基线
+- [~] A. 基线盘点:已新增 FRONTEND_BASELINE_AUDIT(写死清单+交互问题清单+桌面壳性能采集模板)并更新交互矩阵,待桌面壳实机采样数据回填
- [x] B. 默认值配置化:新增 runtimeDefaults 并替换首批散落常量
- [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口
- [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射
diff --git a/docs/FRONTEND_BASELINE_AUDIT.md b/docs/FRONTEND_BASELINE_AUDIT.md
new file mode 100644
index 0000000..c5c13e4
--- /dev/null
+++ b/docs/FRONTEND_BASELINE_AUDIT.md
@@ -0,0 +1,58 @@
+# Frontend Baseline Audit (2026-03-01)
+
+## Scope
+- Frontend path: `ui/frontend/src`
+- Focus:
+ - Runtime hardcoded values inventory
+ - Interaction failure inventory (buttons/dropdowns/modal)
+ - Desktop shell performance baseline template
+
+## Runtime Hardcoded Inventory
+- Cleared:
+ - `COMx/baud/host/port` default values are centralized in `src/config/runtimeDefaults.ts`.
+ - Serial display normalization uses `src/utils/serialPort.ts`.
+ - App version display reads from bridge (`get_app_version`) with fallback.
+- Pending follow-up:
+ - Non-runtime sample values in docs/examples remain intentionally hardcoded for user guidance.
+
+## Interaction Issue Inventory
+- Fixed in this iteration:
+ - `ProxyCaptureToolbar`:
+ - Search input now emits `update:search-keyword`.
+ - "Continue capture" button now emits `resume-capture`.
+ - Settings button now emits `open-settings`.
+ - `ProxyCaptureFooter`:
+ - Pagination buttons now emit `page-first/page-prev/page-next/page-last`.
+ - Export button now emits `export`.
+ - Disabled state now reflects page boundary.
+ - `ProxyCaptureDetails`:
+ - Copy hex button now emits `copy-hex`.
+ - Action buttons now emit `open-rule`.
+ - Copy button has disabled/tooltip behavior when no active frame.
+- Added regression tests:
+ - `src/components/proxy/ProxyCaptureToolbar.test.ts`
+ - `src/components/proxy/ProxyCaptureFooter.test.ts`
+ - `src/components/proxy/ProxyCaptureDetails.test.ts`
+
+## Desktop Shell Performance Baseline Template
+- Environment:
+ - App build: release candidate build from `npm run build`
+ - Device: desktop shell host machine
+ - Duration: 3 minutes continuous operation per page
+- Scenarios:
+ - Manual page: connect/disconnect, dropdown open/select, log burst append
+ - Proxy page: open capture modal, search/filter, page navigation, detail panel operations
+ - Settings page: tab switch, save/discard
+- Metrics to record:
+ - Dropdown open latency P95 (target `< 100ms`)
+ - First visible feedback of primary actions (target `< 120ms`)
+ - Scroll frame drop ratio (target `< 5%`)
+ - Median FPS during continuous operation (target `>= 50`)
+ - Memory trend (no sustained abnormal growth)
+
+## Execution Rule
+- Per PR:
+ - `npm run ci:frontend`
+- Release candidate:
+ - Run matrix in `docs/FRONTEND_INTERACTION_TEST_MATRIX.md`
+ - Fill the baseline template above and attach result to release notes
diff --git a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
index 32788b4..4765976 100644
--- a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
+++ b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
@@ -16,7 +16,11 @@
- Proxy capture panel
- Open/close capture modal from proxy card
- Frame filter (`all/rx/tx/error`) reflects in table list
+ - Search keyword filters frame rows by id/protocol/summary/raw hex
+ - Capture footer pagination buttons (`first/prev/next/last`) are actionable
+ - Capture toolbar actions (`resume/settings`) are actionable
- Select frame updates right details panel
+ - Details panel action buttons (`copy hex`, `open rule`) are actionable
- Logging behavior
- Comm/script logs append without UI freeze during burst input
- Auto-scroll keeps tail pinned when enabled
diff --git a/ui/frontend/src/components/ProxyMonitorView.vue b/ui/frontend/src/components/ProxyMonitorView.vue
index 738be91..66ebc59 100644
--- a/ui/frontend/src/components/ProxyMonitorView.vue
+++ b/ui/frontend/src/components/ProxyMonitorView.vue
@@ -32,6 +32,9 @@ const captureProxy = ref(null)
const captureView = ref('parsed')
const { captureOpen, captureFilter, selectedFrame, openCapture, closeCapture } = useCapturePanel()
const captureTableRef = ref(null)
+const captureSearchKeyword = ref('')
+const captureScrollTop = ref(0)
+const captureViewportHeight = ref(0)
const proxyName = ref('')
const connectionMode = ref(tr('透传模式'))
@@ -288,13 +291,47 @@ function hexCellClass(index, value) {
const filteredFrames = computed(() => {
const safeFrames = captureFrames.value.filter(Boolean)
- if (captureFilter.value === 'all') return safeFrames
- if (captureFilter.value === 'error') {
- return safeFrames.filter((frame) => frame.tone === 'red')
- }
- return safeFrames.filter((frame) => frame.direction === captureFilter.value)
+ const byFilter =
+ captureFilter.value === 'all'
+ ? safeFrames
+ : captureFilter.value === 'error'
+ ? safeFrames.filter((frame) => frame.tone === 'red')
+ : safeFrames.filter((frame) => frame.direction === captureFilter.value)
+ const keyword = String(captureSearchKeyword.value || '').trim().toLowerCase()
+ if (!keyword) return byFilter
+ return byFilter.filter((frame) => {
+ const fields = [
+ frame.id,
+ frame.direction,
+ frame.summary,
+ frame.summaryText,
+ frame.note,
+ frame.protocolLabel,
+ frame.protocolTooltip,
+ frame.channel,
+ ]
+ return fields.some((item) => String(item || '').toLowerCase().includes(keyword))
+ })
+})
+
+const capturePageSize = computed(() => {
+ const rows = Math.floor((captureViewportHeight.value || 0) / 36)
+ return Math.max(1, rows || 1)
+})
+
+const capturePageCount = computed(() => Math.max(1, Math.ceil(filteredFrames.value.length / capturePageSize.value)))
+
+const capturePage = computed(() => {
+ const offset = Math.floor(captureScrollTop.value / (capturePageSize.value * 36))
+ return Math.min(capturePageCount.value, offset + 1)
})
+const captureMetaView = computed(() => ({
+ ...captureMeta.value,
+ page: capturePage.value,
+ pageCount: capturePageCount.value,
+}))
+
const frameWindow = useWindowedList({
itemCount: computed(() => filteredFrames.value.length),
rowHeight: 36,
@@ -309,7 +346,58 @@ const visibleFrames = computed(() => {
function syncCaptureWindow() {
if (!captureTableRef.value) return
- frameWindow.updateViewport(captureTableRef.value.clientHeight || 0, captureTableRef.value.scrollTop || 0)
+ captureViewportHeight.value = captureTableRef.value.clientHeight || 0
+ captureScrollTop.value = captureTableRef.value.scrollTop || 0
+ frameWindow.updateViewport(captureViewportHeight.value, captureScrollTop.value)
+}
+
+function scrollCaptureToPage(targetPage) {
+ if (!captureTableRef.value) return
+ const page = Math.max(1, Math.min(capturePageCount.value, Number(targetPage) || 1))
+ const top = (page - 1) * capturePageSize.value * 36
+ captureTableRef.value.scrollTop = top
+ syncCaptureWindow()
+}
+
+function copyActiveHex() {
+ const content = String(activeFrame.value?.note || '').trim()
+ if (!content) return
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+ navigator.clipboard.writeText(content).catch(() => {})
+ }
+}
+
+function openCaptureRule() {
+ if (!captureProxy.value) return
+ openEditModal(captureProxy.value)
+}
+
+function exportCaptureResults() {
+ const rows = filteredFrames.value.map((frame) => {
+ return [frame.time || '', frame.direction || '', frame.protocolLabel || '', frame.note || '', frame.summaryText || frame.summary || ''].join('\t')
+ })
+ const payload = rows.join('\n')
+ const name = `capture_${new Date().toISOString().replace(/[:.]/g, '-')}.log`
+ const blob = new Blob([payload], { type: 'text/plain' })
+ 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)
+}
+
+function resumeCapture() {
+ if (!captureProxy.value) return
+ if (bridge && bridge.value && bridge.value.start_capture) {
+ bridge.value.start_capture({
+ id: captureProxy.value.id,
+ channel: captureProxy.value.hostPort,
+ hostPort: captureProxy.value.hostPort,
+ })
+ }
}
function openEditModal(proxy) {
@@ -364,6 +452,7 @@ function closeCaptureModal() {
if (bridge && bridge.value && bridge.value.stop_capture) {
bridge.value.stop_capture()
}
+ captureSearchKeyword.value = ''
closeCapture()
}
@@ -676,13 +765,20 @@ onBeforeUnmount(() => {
@mousedown.stop
@click.stop
>
-
+
]
@@ -699,9 +795,18 @@ onBeforeUnmount(() => {
:capture-metrics="captureMetrics"
:hex-cell-class="hexCellClass"
@close="closeCaptureModal"
+ @copy-hex="copyActiveHex"
+ @open-rule="openCaptureRule"
/>
-
+
diff --git a/ui/frontend/src/components/proxy/ProxyCaptureDetails.test.ts b/ui/frontend/src/components/proxy/ProxyCaptureDetails.test.ts
new file mode 100644
index 0000000..87ab61c
--- /dev/null
+++ b/ui/frontend/src/components/proxy/ProxyCaptureDetails.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApp, defineComponent, h, nextTick } from 'vue'
+import ProxyCaptureDetails from './ProxyCaptureDetails.vue'
+
+function mountDetails(activeFrame: any = { note: 'AA BB', protocolLabel: 'modbus' }) {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const events: Array<{ type: string; value?: string }> = []
+
+ const Root = defineComponent({
+ render() {
+ return h(ProxyCaptureDetails, {
+ activeFrame,
+ isUnknownFrame: false,
+ activeProtocolLabel: 'modbus',
+ activeHexSize: 2,
+ activeHexCells: ['AA', 'BB'],
+ activeHexAscii: ['..', '..'],
+ activeTreeRows: [],
+ captureMetrics: { rtt: '1ms', loss: '0%' },
+ hexCellClass: () => '',
+ onClose: () => events.push({ type: 'close' }),
+ onCopyHex: () => events.push({ type: 'copy' }),
+ onOpenRule: (mode: string) => events.push({ type: 'rule', value: mode }),
+ })
+ },
+ })
+
+ 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('ProxyCaptureDetails', () => {
+ it('emits copy/close and rule actions', async () => {
+ const vm = mountDetails()
+ const buttons = vm.host.querySelectorAll('button')
+ ;(buttons[0] as HTMLButtonElement).click()
+ ;(buttons[1] as HTMLButtonElement).click()
+ ;(buttons[2] as HTMLButtonElement).click()
+ ;(buttons[3] as HTMLButtonElement).click()
+ await tick()
+ expect(vm.events).toEqual([
+ { type: 'copy' },
+ { type: 'close' },
+ { type: 'rule', value: 'inspect' },
+ { type: 'rule', value: 'configure' },
+ ])
+ vm.unmount()
+ })
+})
diff --git a/ui/frontend/src/components/proxy/ProxyCaptureDetails.vue b/ui/frontend/src/components/proxy/ProxyCaptureDetails.vue
index e67d912..bbc2bb0 100644
--- a/ui/frontend/src/components/proxy/ProxyCaptureDetails.vue
+++ b/ui/frontend/src/components/proxy/ProxyCaptureDetails.vue
@@ -42,11 +42,19 @@ defineProps({
},
})
-const emit = defineEmits(['close'])
+const emit = defineEmits(['close', 'copy-hex', 'open-rule'])
function onClose() {
emit('close')
}
+
+function onCopyHex() {
+ emit('copy-hex')
+}
+
+function onOpenRule(mode) {
+ emit('open-rule', mode)
+}
@@ -56,10 +64,16 @@ function onClose() {
info{{ tr('报文解析详情') }}
-
@@ -84,10 +98,18 @@ function onClose() {
-
+
{{ isUnknownFrame ? tr('手动解析') : tr('查看详情') }}
-
+
schema
{{ isUnknownFrame ? tr('配置解析规则') : tr('调整解析规则') }}
diff --git a/ui/frontend/src/components/proxy/ProxyCaptureFooter.test.ts b/ui/frontend/src/components/proxy/ProxyCaptureFooter.test.ts
new file mode 100644
index 0000000..bd54a1c
--- /dev/null
+++ b/ui/frontend/src/components/proxy/ProxyCaptureFooter.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApp, defineComponent, h, nextTick, ref } from 'vue'
+import ProxyCaptureFooter from './ProxyCaptureFooter.vue'
+
+function mountFooter(metaValue: any) {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const events: string[] = []
+ const meta = ref(metaValue)
+
+ const Root = defineComponent({
+ render() {
+ return h(ProxyCaptureFooter, {
+ captureMeta: meta.value,
+ onPageFirst: () => events.push('first'),
+ onPagePrev: () => events.push('prev'),
+ onPageNext: () => events.push('next'),
+ onPageLast: () => events.push('last'),
+ onExport: () => events.push('export'),
+ })
+ },
+ })
+
+ 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('ProxyCaptureFooter', () => {
+ it('emits page and export actions when enabled', async () => {
+ const vm = mountFooter({ page: 2, pageCount: 4, bufferUsed: 10, rangeStart: 1, rangeEnd: 20, totalFrames: 99 })
+ const buttons = vm.host.querySelectorAll('button')
+ ;(buttons[0] as HTMLButtonElement).click()
+ ;(buttons[1] as HTMLButtonElement).click()
+ ;(buttons[2] as HTMLButtonElement).click()
+ ;(buttons[3] as HTMLButtonElement).click()
+ ;(buttons[4] as HTMLButtonElement).click()
+ await tick()
+ expect(vm.events).toEqual(['first', 'prev', 'next', 'last', 'export'])
+ vm.unmount()
+ })
+})
diff --git a/ui/frontend/src/components/proxy/ProxyCaptureFooter.vue b/ui/frontend/src/components/proxy/ProxyCaptureFooter.vue
index b66a02a..b31e8bb 100644
--- a/ui/frontend/src/components/proxy/ProxyCaptureFooter.vue
+++ b/ui/frontend/src/components/proxy/ProxyCaptureFooter.vue
@@ -3,12 +3,17 @@ import { inject } from 'vue'
const tr = inject('tr', (text) => text)
-defineProps({
+const props = defineProps({
captureMeta: {
type: Object,
default: () => ({}),
},
})
+
+const emit = defineEmits(['page-first', 'page-prev', 'page-next', 'page-last', 'export'])
+
+const canPrev = () => Number(props.captureMeta.page || 1) > 1
+const canNext = () => Number(props.captureMeta.page || 1) < Number(props.captureMeta.pageCount || 1)
@@ -23,26 +28,31 @@ defineProps({
- {{ tr('显示') }} {{ captureMeta.rangeStart }}-{{ captureMeta.rangeEnd }} / {{ tr('共') }} {{ captureMeta.totalFrames }} {{ tr('报文') }}
+ {{ tr('显示') }} {{ captureMeta.rangeStart }}-{{ captureMeta.rangeEnd }} / {{ tr('共') }} {{ captureMeta.totalFrames }}
+ {{ tr('报文') }}
-
+
first_page
-
+
chevron_left
{{ tr('第') }} {{ captureMeta.page }} / {{ captureMeta.pageCount }} {{ tr('页') }}
-
+
chevron_right
-
+
last_page
-
+
download{{ tr('导出分析结果') }}
diff --git a/ui/frontend/src/components/proxy/ProxyCaptureToolbar.test.ts b/ui/frontend/src/components/proxy/ProxyCaptureToolbar.test.ts
new file mode 100644
index 0000000..dfa2cf4
--- /dev/null
+++ b/ui/frontend/src/components/proxy/ProxyCaptureToolbar.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApp, defineComponent, h, nextTick, ref } from 'vue'
+import ProxyCaptureToolbar from './ProxyCaptureToolbar.vue'
+
+function mountToolbar() {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const events: Array<{ type: string; value?: string }> = []
+ const keyword = ref('')
+
+ const Root = defineComponent({
+ render() {
+ return h(ProxyCaptureToolbar, {
+ captureProxy: { hostPort: 'COM3' },
+ captureMeta: { channel: 'COM3', engine: 'x' },
+ searchKeyword: keyword.value,
+ 'onUpdate:search-keyword': (value: string) => {
+ keyword.value = value
+ events.push({ type: 'search', value })
+ },
+ onResumeCapture: () => events.push({ type: 'resume' }),
+ onOpenSettings: () => events.push({ type: 'settings' }),
+ })
+ },
+ })
+
+ 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('ProxyCaptureToolbar', () => {
+ it('emits search, resume and settings actions', async () => {
+ const vm = mountToolbar()
+ const input = vm.host.querySelector('input') as HTMLInputElement
+ input.value = 'modbus'
+ input.dispatchEvent(new Event('input'))
+ const buttons = vm.host.querySelectorAll('button')
+ ;(buttons[0] as HTMLButtonElement).click()
+ ;(buttons[1] as HTMLButtonElement).click()
+ await tick()
+
+ expect(vm.events).toEqual([{ type: 'search', value: 'modbus' }, { type: 'resume' }, { type: 'settings' }])
+ vm.unmount()
+ })
+})
diff --git a/ui/frontend/src/components/proxy/ProxyCaptureToolbar.vue b/ui/frontend/src/components/proxy/ProxyCaptureToolbar.vue
index 7e5e1cc..ff03df7 100644
--- a/ui/frontend/src/components/proxy/ProxyCaptureToolbar.vue
+++ b/ui/frontend/src/components/proxy/ProxyCaptureToolbar.vue
@@ -3,7 +3,7 @@ import { inject } from 'vue'
const tr = inject('tr', (text) => text)
-defineProps({
+const props = defineProps({
captureProxy: {
type: Object,
default: null,
@@ -12,7 +12,25 @@ defineProps({
type: Object,
default: () => ({}),
},
+ searchKeyword: {
+ type: String,
+ default: '',
+ },
})
+
+const emit = defineEmits(['update:search-keyword', 'resume-capture', 'open-settings'])
+
+function onSearchInput(event) {
+ emit('update:search-keyword', event?.target?.value || '')
+}
+
+function onResumeCapture() {
+ emit('resume-capture')
+}
+
+function onOpenSettings() {
+ emit('open-settings')
+}
@@ -27,7 +45,8 @@ defineProps({
- {{ tr('活动通道') }}: {{ captureProxy ? captureProxy.hostPort : captureMeta.channel }} | {{ tr('引擎状态') }}: {{ tr(captureMeta.engine) }}
+ {{ tr('活动通道') }}: {{ captureProxy ? captureProxy.hostPort : captureMeta.channel }} | {{ tr('引擎状态') }}:
+ {{ tr(captureMeta.engine) }}
@@ -39,13 +58,19 @@ defineProps({
class="bg-transparent border-none text-xs w-64 lg:w-96 focus:ring-0 text-slate-900 placeholder-slate-500"
:placeholder="tr('搜索标识、十六进制、协议、原始数据...')"
type="text"
+ :value="props.searchKeyword"
+ @input="onSearchInput"
/>
-
+
play_arrow{{ tr('继续捕获') }}
-
+
settings
From d669bc5d0b796d5bf89fd5b2e70cb7ca25787d15 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 19:08:52 +0800
Subject: [PATCH 063/145] feat(frontend): add interaction acceptance gate to ci
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- add interaction acceptance gate to ci
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- add interaction acceptance gate to ci
- 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 +-
docs/FRONTEND_INTERACTION_TEST_MATRIX.md | 1 +
ui/frontend/package.json | 3 +-
.../scripts/interaction_acceptance.mjs | 54 +++++++++++++++++++
4 files changed, 58 insertions(+), 2 deletions(-)
create mode 100644 ui/frontend/scripts/interaction_acceptance.mjs
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index 5cfa099..eeb0d05 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -8,4 +8,4 @@
- [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/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview 回归测试
+- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本、交互契约验收脚本(acceptance:interaction)与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview/ProxyCapture 回归测试
diff --git a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
index 4765976..fb5b464 100644
--- a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
+++ b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
@@ -38,4 +38,5 @@
## Execution Cadence
- Per PR: run `npm run ci:frontend`
+- Contract gate: `npm run acceptance:interaction`
- Release candidate: run manual matrix on desktop shell
diff --git a/ui/frontend/package.json b/ui/frontend/package.json
index e8f7086..a69add9 100644
--- a/ui/frontend/package.json
+++ b/ui/frontend/package.json
@@ -9,8 +9,9 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
+ "acceptance:interaction": "node ./scripts/interaction_acceptance.mjs",
"perf:report": "node ./scripts/perf_report.mjs",
- "ci:frontend": "npm run test && npm run build && npm run perf:report"
+ "ci:frontend": "npm run test && npm run acceptance:interaction && npm run build && npm run perf:report"
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
diff --git a/ui/frontend/scripts/interaction_acceptance.mjs b/ui/frontend/scripts/interaction_acceptance.mjs
new file mode 100644
index 0000000..7f5b072
--- /dev/null
+++ b/ui/frontend/scripts/interaction_acceptance.mjs
@@ -0,0 +1,54 @@
+import fs from 'node:fs'
+import path from 'node:path'
+
+const root = process.cwd()
+
+function read(file) {
+ const full = path.join(root, file)
+ if (!fs.existsSync(full)) {
+ throw new Error(`Missing file: ${file}`)
+ }
+ return fs.readFileSync(full, 'utf8')
+}
+
+function expectIncludes(content, needle, label, failures) {
+ if (!content.includes(needle)) {
+ failures.push(`${label}: missing "${needle}"`)
+ }
+}
+
+function run() {
+ const failures = []
+
+ const toolbar = read('src/components/proxy/ProxyCaptureToolbar.vue')
+ expectIncludes(toolbar, "defineEmits(['update:search-keyword', 'resume-capture', 'open-settings'])", 'ProxyCaptureToolbar emits', failures)
+
+ const footer = read('src/components/proxy/ProxyCaptureFooter.vue')
+ expectIncludes(footer, "defineEmits(['page-first', 'page-prev', 'page-next', 'page-last', 'export'])", 'ProxyCaptureFooter emits', failures)
+
+ const details = read('src/components/proxy/ProxyCaptureDetails.vue')
+ expectIncludes(details, "defineEmits(['close', 'copy-hex', 'open-rule'])", 'ProxyCaptureDetails emits', failures)
+
+ const monitor = read('src/components/ProxyMonitorView.vue')
+ expectIncludes(monitor, '@update:search-keyword="captureSearchKeyword = $event"', 'ProxyMonitorView toolbar binding', failures)
+ expectIncludes(monitor, '@resume-capture="resumeCapture"', 'ProxyMonitorView resume binding', failures)
+ expectIncludes(monitor, '@open-settings="openCaptureRule"', 'ProxyMonitorView settings binding', failures)
+ expectIncludes(monitor, '@page-first="scrollCaptureToPage(1)"', 'ProxyMonitorView footer first page binding', failures)
+ expectIncludes(monitor, '@page-last="scrollCaptureToPage(captureMetaView.pageCount)"', 'ProxyMonitorView footer last page binding', failures)
+ expectIncludes(monitor, '@copy-hex="copyActiveHex"', 'ProxyMonitorView details copy binding', failures)
+ expectIncludes(monitor, '@open-rule="openCaptureRule"', 'ProxyMonitorView details rule binding', failures)
+
+ const matrix = read('../../docs/FRONTEND_INTERACTION_TEST_MATRIX.md')
+ expectIncludes(matrix, 'Search keyword filters frame rows by id/protocol/summary/raw hex', 'Interaction matrix coverage', failures)
+ expectIncludes(matrix, 'Capture footer pagination buttons (`first/prev/next/last`) are actionable', 'Interaction matrix pagination coverage', failures)
+
+ if (failures.length) {
+ console.error('Frontend interaction acceptance failed:')
+ failures.forEach((item) => console.error(`- ${item}`))
+ process.exit(1)
+ }
+
+ console.log('Frontend interaction acceptance passed.')
+}
+
+run()
From 0462fe1b9dc6d19c0354106c559ff929c83066fc Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 19:11:25 +0800
Subject: [PATCH 064/145] ci(frontend): enforce button interaction audit in
acceptance gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- enforce button interaction audit in acceptance gate
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- enforce button interaction audit in acceptance gate
- 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 +-
docs/FRONTEND_INTERACTION_TEST_MATRIX.md | 1 +
.../scripts/interaction_acceptance.mjs | 37 +++++++++++++++++++
ui/frontend/src/components/SettingsPanels.vue | 7 +++-
4 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index eeb0d05..fe6ee05 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -8,4 +8,4 @@
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
-- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本、交互契约验收脚本(acceptance:interaction)与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview/ProxyCapture 回归测试
+- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本、交互契约验收脚本(acceptance:interaction,含 Proxy 抓包交互接线与按钮无效态审计)与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview/ProxyCapture 回归测试
diff --git a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
index fb5b464..62b3b49 100644
--- a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
+++ b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md
@@ -39,4 +39,5 @@
## Execution Cadence
- Per PR: run `npm run ci:frontend`
- Contract gate: `npm run acceptance:interaction`
+- Button audit: contract gate includes "no clickable-looking button without action/disabled" check for `src/components` (excluding `ui-kit`)
- Release candidate: run manual matrix on desktop shell
diff --git a/ui/frontend/scripts/interaction_acceptance.mjs b/ui/frontend/scripts/interaction_acceptance.mjs
index 7f5b072..37f3313 100644
--- a/ui/frontend/scripts/interaction_acceptance.mjs
+++ b/ui/frontend/scripts/interaction_acceptance.mjs
@@ -17,6 +17,42 @@ function expectIncludes(content, needle, label, failures) {
}
}
+function listVueFiles(dir, output = []) {
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
+ for (const entry of entries) {
+ if (entry.name === 'ui-kit') continue
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ listVueFiles(full, output)
+ continue
+ }
+ if (entry.isFile() && entry.name.endsWith('.vue')) {
+ output.push(full)
+ }
+ }
+ return output
+}
+
+function auditButtonsWithoutInteraction(failures) {
+ const componentsRoot = path.join(root, 'src', 'components')
+ const files = listVueFiles(componentsRoot)
+ const ignoredNames = new Set(['HelloWorld.vue'])
+ const buttonPattern = //g
+ for (const file of files) {
+ if (ignoredNames.has(path.basename(file))) continue
+ const content = fs.readFileSync(file, 'utf8')
+ const buttons = content.match(buttonPattern) || []
+ for (const tag of buttons) {
+ const hasClick = /@click|v-on:click/.test(tag)
+ const isDisabled = /\bdisabled\b|:disabled=/.test(tag)
+ const isSubmit = /type="submit"|type='submit'/.test(tag)
+ if (hasClick || isDisabled || isSubmit) continue
+ const rel = path.relative(root, file).replace(/\\/g, '/')
+ failures.push(`Button interaction audit: ${rel} has clickable-looking button without @click/disabled`)
+ }
+ }
+}
+
function run() {
const failures = []
@@ -41,6 +77,7 @@ function run() {
const matrix = read('../../docs/FRONTEND_INTERACTION_TEST_MATRIX.md')
expectIncludes(matrix, 'Search keyword filters frame rows by id/protocol/summary/raw hex', 'Interaction matrix coverage', failures)
expectIncludes(matrix, 'Capture footer pagination buttons (`first/prev/next/last`) are actionable', 'Interaction matrix pagination coverage', failures)
+ auditButtonsWithoutInteraction(failures)
if (failures.length) {
console.error('Frontend interaction acceptance failed:')
diff --git a/ui/frontend/src/components/SettingsPanels.vue b/ui/frontend/src/components/SettingsPanels.vue
index 62ccbd1..179c890 100644
--- a/ui/frontend/src/components/SettingsPanels.vue
+++ b/ui/frontend/src/components/SettingsPanels.vue
@@ -107,7 +107,12 @@ const emit = defineEmits([
{{ t('settings.plugins.title') }}
-
+
refresh
{{ t('settings.plugins.refresh') }}
From 0e0a107495a4364ae3d725ba61c0525b8fea09c7 Mon Sep 17 00:00:00 2001
From: 11cookies11
Date: Sun, 1 Mar 2026 19:15:37 +0800
Subject: [PATCH 065/145] perf(frontend): lazy-load app views and codemirror
deps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[中文]
- 变更内容:
- lazy-load app views and codemirror deps
- 影响范围:
- 详见本次提交改动文件
- 兼容性/行为变化:
- 无破坏性接口变更(如有以代码为准)
- 依赖/环境:
- 无新增依赖(如有以代码为准)
- 验证:
- 已完成对应构建/测试(如有以提交记录为准)
Refs:
- 无
[English]
- Changes:
- lazy-load app views and codemirror deps
- 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 +-
docs/FRONTEND_PERFORMANCE_GUARDRAIL.md | 1 +
ui/frontend/src/App.vue | 74 +++++++++++++++++++-------
3 files changed, 56 insertions(+), 21 deletions(-)
diff --git a/.where-agent-progress.md b/.where-agent-progress.md
index fe6ee05..ad6bc17 100644
--- a/.where-agent-progress.md
+++ b/.where-agent-progress.md
@@ -7,5 +7,5 @@
- [x] F. App 逻辑下沉:已完成 channel 端口与连接动作、通道同步刷新、bridge 挂载轮询初始化、日志缓冲刷新与状态发射、异步结果处理统一(utils/withResult)、通信批量解析、通信日志格式化导出、抓包帧映射与接入、快捷指令管理(增删改/弹窗/归一化/计数)等逻辑下沉到 composables,App.vue 主要保留页面编排与绑定
- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分
- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑
-- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流
+- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流,并完成 App 视图异步分包与 CodeMirror 依赖延迟加载(主包告警消除,perf budget 仍满足)
- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本、交互契约验收脚本(acceptance:interaction,含 Proxy 抓包交互接线与按钮无效态审计)与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview/ProxyCapture 回归测试
diff --git a/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md b/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md
index 652c896..ea80474 100644
--- a/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md
+++ b/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md
@@ -25,3 +25,4 @@ npm run ci:frontend
- Budget report checks built assets under `dist/assets`
- If budget fails, process exits with non-zero code
- Bundle budget complements runtime interaction checks, not a replacement
+- 2026-03-01 update: App views and CodeMirror runtime are async-split/lazy-loaded, and bundle warning is removed in default build output.
diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue
index 14d47a6..6906e20 100644
--- a/ui/frontend/src/App.vue
+++ b/ui/frontend/src/App.vue
@@ -1,23 +1,5 @@