node-red-contrib-room-tools 1.8.16 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,125 +1,182 @@
1
1
  # ControlPanel 前端页面编写指南
2
2
 
3
- 适用基线:`node-red-contrib-room-tools@1.8.9` 的 `ui-control-panel`,当前本地修订为 `node-red-contrib-room-tools@1.8.15`,Dashboard 2,Vue Options API。本文按当前代码审核,不表示支持任意 Vue、Vuetify 或第三方组件。
3
+ 适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.8.16`、Dashboard 2 和 Vue Options API。
4
4
 
5
- 给 AI 编写页面前阅读本文。目标是:页面作者只写普通前端页面;部署人员在网页编辑模式选择设备与点位;ControlPanel 保存映射并负责设备命令、反馈和网页侧量程换算。
5
+ 本文件是给页面编写 AI 的实现契约,不是完整的 Vue 或 Vuetify 手册。目标是:页面作者只写普通 HTML、CSS 和 Vue 页面;设备、驱动、映射表、反馈订阅和通信细节全部由 `ControlPanel` 节点处理。
6
6
 
7
- ## 1. 职责边界
7
+ ## 1. 最重要的规则
8
8
 
9
- 页面作者使用普通 HTML、CSS 和 Vue 编写布局、样式、导航、控件及其本地状态。无需知道驱动协议、点位路径、内部 Control ID 或绑定表结构。标准 HTML `id`、Vue `v-model` 和 ARIA 属性不是 ControlPanel 专用标签。
9
+ 页面作者只需要:
10
10
 
11
- 节点负责登录界面、普通与编辑权限、控件识别、映射保存、桥接生成、命令发送和反馈更新。页面不调用这些内部服务,不实现第二套设备通信。即使一个页面同时显示几十或上百个实时 Meter,页面也只声明普通控件和普通 Vue 数据字段,并用 `volume` 与 `meter1` 等不同字段表达不同语义。
11
+ - 按普通 H5/Vue 方式写布局、样式、导航、控件和页面本地状态。
12
+ - 为需要绑定的控件准备简单、可写、类型正确的 Vue 数据字段。
13
+ - 使用稳定的普通 HTML `id` 或 `name`,并为重复列表使用稳定的 `:key`。
14
+ - 用 `volume`、`muted`、`source`、`meter` 等字段表达页面语义。字段名只是页面模型名,不需要使用任何内部 Control ID。
12
15
 
13
- 不得从旧页面复制以下内容:
16
+ 页面作者不需要知道:
14
17
 
15
- - 手写 `data-cp-*` 属性。这些属性会被拒绝;运行时自动添加的内部标记不属于页面交付代码。
16
- - `sendControl`、`postControl`、`setControlSession`、`deviceValue` 等旧页面接口。
17
- - 写死设备路径、驱动命令、绑定表,或调用 ControlPanel 的 HTTP、WebSocket、SSE 接口。
18
- - 声明 stream ID、订阅主题、帧格式、Socket.IO 事件或批量反馈协议。
19
- - 为修复反馈而添加设备轮询、强制回写滑杆、重复发送监听器或设备状态定时器。
18
+ - 驱动协议、设备路径、编号方式或 `program.json`。
19
+ - ControlPanel 的映射 JSON、桥接节点、stream、socket、SSE、帧格式或订阅主题。
20
+ - 内部 `data-cp-*` 标记、ControlPanel HTTP API 或设备发送接口。
21
+ - 量程换算、连续值节流、latest-wins、命令队列和反馈重连。
20
22
 
21
- 这些限制针对设备绑定,不禁止正常的页面导航、CSS 动画、图片明暗变化等纯前端功能。未绑定控件不会自动控制设备。待绑定控件的模型应独立;不要让两个不同设备控件共用一个 `v-model` 字段。
23
+ 不要在页面里实现第二套设备通信。不要写设备轮询、定时回写滑杆、反馈回写命令、重复发送监听器或自定义发送函数。未绑定的控件不会自动控制设备。
22
24
 
23
- ## 2. 页面格式
25
+ 页面里的 `volume` 和 `meter` 是两个不同语义:
24
26
 
25
- 交付可直接粘贴到节点 Template 的组件片段:一个 `<template>`,可选的 `<script>`、`<style>`。不要交付带 `<html>`、`<head>`、`<body>` 的完整文档,也不要交付需要外部构建才能运行的项目。
27
+ - `volume` 等普通数值字段用于音量/增益等可写连续控制,并可接收普通真实值反馈。
28
+ - `meter` 等字段用于只读实时信号电平,只能接收驱动声明了 Meter 元数据的点位。
26
29
 
27
- 脚本使用 `export default { data() { ... }, computed: { ... }, methods: { ... } }`。待绑定字段必须是 `data()` 中的顶层、可写、简单字段,例如 `volume`、`muted`、`source`、`status`。不支持将 `state.volume`、数组下标、计算属性、函数调用或表达式作为绑定模型;不使用 `<script setup>`、组件导入或自行创建 Vue 应用。
30
+ 不要把同一个模型字段同时用于不同设备点位,尤其不要让 Meter 和 Volume 共用一个模型。
28
31
 
29
- 为待绑定控件设置稳定、唯一的普通 `id`,尤其是重复控件。也可使用标准 `name`,但优先使用 `id`。不要根据运行时随机值生成 ID。改动 ID、标签类型或绑定写法后,原映射可能失效,需要重新检查。
32
+ ## 2. 页面格式与 Vue 约束
30
33
 
31
- 所有 Vuetify 标签都成对闭合,包括装饰组件:`<v-icon></v-icon>`,不要写 `<v-icon/>`。Dashboard 的 HTML 渲染可能错误地吞入后续元素,节点会拒绝这种自闭合写法。原生 HTML 的 void 元素,如 `<input>`、`<img>`,不受此限制。
34
+ 交付可以直接粘贴到节点 Template 的组件片段:一个外层 `<template>`,可选的 `<script>` 和 `<style>`。不要交付带 `<html>`、`<head>`、`<body>` 的完整文档,也不要交付需要外部构建才能运行的项目。
32
35
 
33
- ## 3. 可绑定控件
34
-
35
- 以下表格是当前自动绑定适配器的范围,不是整个 Vuetify 的组件清单。
36
-
37
- | 用途 | 原生 HTML | Vuetify | 数据与提交方式 |
38
- | --- | --- | --- | --- |
39
- | 连续数值 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;原生 input/change 发送,Vuetify 模型变化发送并在变化停止后提交最终值。 |
40
- | 布尔开关 | `input[type=checkbox]`、语义开关按钮 | `v-switch`、`v-checkbox` | 布尔值;切换发送。语义按钮写法见下节。 |
41
- | 单选枚举 | `select` | `v-select`、`v-radio-group` | 字符串或数字的单个值,必须与设备枚举值对应。 |
42
- | 文本输入 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生 change 提交,Vuetify 在 change 或失焦时提交,`v-text-field` 还支持 Enter。 |
43
- | 普通只读数值 | `output`、文本展示 | 暂无 | 只绑定普通 Feedback point,不发送命令。使用简单顶层字段;不要把普通数值反馈写成 Meter。 |
44
- | 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只绑定驱动明确声明 Meter 元数据的 Feedback point,不发送命令。使用独立的 `meter`/`meter1` 字段;原生 `meter`/`progress` 可使用简单 `v-model` 作为只读别名。 |
45
- | 只读文本 | `span`、`strong`、`b`、`output`、`h1`、`h2`、`h3` | 暂无 | 使用简单字段插值,如 `{{ status }}`,或由节点替换的纯文本;只绑定读点位。 |
46
-
47
- 原生输入可以不写 Vue 模型,也支持简单顶层 `v-model`。Vuetify 交互控件必须写简单 `v-model`;进度组件可以使用简单 `:model-value="progress"`。初始化数值为数字、开关为布尔值、文本为字符串,不用数组、对象或空值占位。
36
+ 脚本使用 Vue Options API:
48
37
 
49
- ### 3.1 通用限制
50
-
51
- - 待绑定控件自身不能有 `@click`、`@input`、`@change`、`@update:model-value` 等任何事件指令。即使事件只做本地操作,也会被当前识别器判为已有逻辑。页面导航等事件可放在独立、不绑定设备的控件上。
52
- - 交互输入控件不额外叠加 `:value`、`:checked`、`:model-value`。只读原生 meter/progress 的 `:value`、Vuetify 进度组件的 `:model-value` 和语义开关按钮的简单 ARIA 布尔绑定是例外。
53
- - 只允许 range/select 的 `v-model.number`;不要用 `.lazy`、`.trim`、带参数的 `v-model` 或其他修饰符。
54
- - 待绑定元素不能位于 `v-for` 中,即使写了 `:key`。相同模板会生成多个元素,当前不能按列表项分别绑定。
55
- - 标签页、`v-if` 等正常布局可以保留。编辑该控件时先让它显示出来,并确保同一定位只出现一个元素。绑定提示会随元素重建恢复。
56
- - 普通 Vuetify 布局组件可使用,但 `v-btn`、`v-range-slider`、`v-autocomplete`、`v-combobox`、原生 radio 等不在自动设备绑定清单中。不要假定外观相似就支持。
38
+ ```js
39
+ export default {
40
+ data () {
41
+ return { volume: 0, muted: false, status: '待机' }
42
+ },
43
+ computed: {},
44
+ methods: {}
45
+ }
46
+ ```
57
47
 
58
- ### 3.2 各类控件限制
48
+ 不要使用 `<script setup>`、组件导入、自行创建 Vue 应用或外部运行时依赖。普通绑定模型应是 `data()` 中的顶层字段,或本指南 3.2 节规定的列表模型。计算属性、函数调用、取反、条件表达式和动态索引不能作为绑定模型。
59
49
 
60
- - 数值:`min`、`max`、`step` 用固定数字,也可用 `:min="0"`、`:max="100"`、`:step="1"`。不能绑定到动态字段或表达式。`v-slider` 要求 min < max、step > 0,不支持 `step="any"`。
61
- - 原生 range 默认 0–100;原生 meter/progress 默认 0–1。原生 number 没有缺省完整量程;需要自动缩放时明确写 min/max。不要把原生缺省量程误当成设备量程。
62
- - 原生 `meter`/`progress` 可使用标准 `:value="meter1"`,也可使用简单 `v-model="meter1"` 作为只读别名;节点会在编译前将后者转换为 `:value`。不支持 `v-model.number`、动态表达式或同时写 `:value`。只有驱动点位带有 Meter 元数据时,`meter`/`progress` 才能建立绑定;它由独立的 Meter 批量帧通道更新,不能接收网页设置。音量滑杆的 `volume` 与 Meter 的 `meter` 必须是两个不同字段,不能把同一个字段绑定到两个不同设备点位。普通 volume/level 反馈不能选择为 Meter。Vuetify `progress` 组件仍使用简单 `:model-value`;未绑定的进度条可以作为普通前端视觉元素。
63
- - Vuetify switch/checkbox 不支持 `value`、自定义 `true-value`/`false-value`、`multiple` 或数组模型。
64
- - Vuetify select 不支持多选、`return-object`、`clearable` 或对象模型;选项可用标量数组,或普通 `{ title, value }` 选项但模型必须是标量。`v-radio-group` 使用标量模型,子项也不能自带事件处理器。
65
- - Vuetify 文本控件只用字符串模型。`v-text-field` 使用文本类型,不用数字、密码等类型;不支持 `.number`。`v-textarea` 的 Enter 是换行,不是提交。
66
- - `v-progress-linear` 从 0 到固定正数 max,默认 max=100;不支持 min、step、indeterminate、clickable。`v-progress-circular` 固定 0–100,不声明 min/max/step,也不使用 indeterminate。
67
- - 文本展示推荐只放一个简单插值,如 `<span>{{ status }}</span>`,不使用 `status.toFixed()`、拼接表达式或多个插值。复杂展示可用另外的纯前端元素,不把它作为设备反馈绑定目标。
50
+ 不要手写任何 `data-cp-*` 属性。节点会在渲染副本中自动加入内部标记,页面源码不应依赖这些标记;手写此类属性会被拒绝。
68
51
 
69
- ### 3.3 Meter 实时信号与批量反馈
52
+ 不要给绑定控件添加 `@click`、`@input`、`@change`、`@update:model-value` 等事件指令,也不要额外绑定 `:value`、`:checked` 或 `:model-value`。节点需要独占这些事件和模型更新。页面导航、标签切换、纯视觉交互等事件可以放在不绑定设备的独立控件上。
70
53
 
71
- 页面作者不编写订阅、socket 或批处理代码,但要用不同字段表达不同语义:音量/增益使用 `volume` 等普通可写字段;实时信号使用 `meter`、`meter1` 等只读字段。部署人员在绑定编辑页选择 Feedback point;只有驱动明确声明 Meter 元数据的点位才会进入 Meter 列表,ControlPanel 会在运行时自动采用批量反馈通道,并把同一帧的数据一次性更新到本页对应的所有表头。
54
+ 以下是两个例外:
72
55
 
73
- 页面不需要也不得声明设备、驱动、stream、socket、帧序号、点位路径或 ControlPanel API。断线、过期帧、重连、同帧合并和页面量程换算都由节点处理。反馈短暂失效时节点保留最后显示值并标记无效;页面作者不添加轮询、定时器或回写逻辑。标签页、`v-if` 等使控件重新创建后,节点会重新关联控件。
56
+ - 只读原生 `<meter>`/`<progress>` 可以使用 `:value`。
57
+ - 只读 `v-progress-linear`/`v-progress-circular` 可以使用简单 `v-model` 或 `:model-value`。
58
+ - 语义开关按钮可以使用简单的 `:aria-pressed` 或 `:aria-checked`。
74
59
 
75
- 多个表头必须使用不同的 `id` 和不同的顶层字段。不要让属于不同设备点位的表头共用同一个字段;同一个滑杆及其纯视觉旁显可以共享字段,但只能绑定其中一个控件。Meter 与 volume 是完全独立的通信类型:volume 负责音量/增益设置和普通真实值反馈,Meter 只负责某音频通道的高频实时信号电平反馈。代码或驱动中的历史 `level` 名称不自动表示 Meter;是否具备 Meter 能力由点位的 Meter 元数据决定。
60
+ 所有 Vuetify 组件标签都成对闭合,例如 `<v-icon></v-icon>`。Dashboard 的模板渲染可能吞掉自闭合组件后面的内容,节点会在部署前拒绝 `<v-slider />`、`<v-progress-linear />` 等写法。原生 HTML void 元素,如 `<input>` 和 `<img>`,不受此限制。
76
61
 
77
- ### 3.4 普通按钮与开关按钮
62
+ ## 3. 可绑定控件
78
63
 
79
- 一个只有文字的普通 `<button>` 不会自动成为设备控件。需要布尔设备开关时使用标准语义:
64
+ 以下是当前适配器支持的范围,不代表任意 Vuetify 或第三方组件都支持。
80
65
 
81
- ```html
82
- <button id="room-power" type="button" :aria-pressed="powered">电源</button>
83
- <button id="room-mute" type="button" role="switch" :aria-checked="muted">静音</button>
84
- ```
66
+ | 用途 | 原生 HTML | Vuetify | 模型与行为 |
67
+ | --- | --- | --- | --- |
68
+ | 连续数值控制 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;range 在拖动时发送中间值,结束时发送最终值。 |
69
+ | 布尔控制 | `input[type=checkbox]`、带语义属性的 `button` | `v-switch`、`v-checkbox` | 布尔值;切换发送。 |
70
+ | 单选枚举 | `select` | `v-select`、`v-radio-group` | 单个字符串或数字,必须是设备允许的枚举值。 |
71
+ | 文本控制 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生控件在 change 时提交,Vuetify 文本框在 change、失焦时提交,`v-text-field` 也支持 Enter 提交。 |
72
+ | 普通只读反馈 | `span`、`output` 等简单文本展示 | 暂无专用组件 | 只读普通反馈,不发送命令。 |
73
+ | 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只读数值;必须绑定驱动的 Meter 点位,不发送命令。 |
85
74
 
86
- 在 `data()` 中将 `powered`、`muted` 初始化为布尔值。也可使用静态 `aria-pressed="false"` 或 `role="switch" aria-checked="false"`,由节点更新按钮状态。不要叠加点击事件。`aria-pressed`/`aria-checked` 的动态表达式只写顶层字段,不写取反、条件或函数。
75
+ 交互控件建议写简单顶层 `v-model`。原生 range/select 可以使用 `.number`;不要给布尔、文本或 Meter 加 `.number`。Vuetify 进度组件可以使用简单 `v-model`,也可以使用简单 `:model-value`。原生 Meter/Progress 可以使用简单顶层 `v-model`,节点会把它转换成只读 `:value`;在 `v-for` 列表中应直接写 `:value="item.meter"`,不要写嵌套字段的原生 `v-model`。
87
76
 
88
- ## 4. 示例
77
+ 初始化值应匹配控件类型:数值为数字,开关为布尔值,文本和枚举为字符串。不要用数组、对象或 `null` 作为普通绑定控件的占位值。
89
78
 
90
- ### 4.1 原生 H5 控件
79
+ ### 3.1 普通控件写法
91
80
 
92
81
  ```html
93
82
  <template>
94
83
  <section>
95
84
  <label for="room-volume">音量</label>
96
85
  <input id="room-volume" type="range" min="0" max="100" step="1" v-model.number="volume">
97
- <label><input id="room-mute" type="checkbox" v-model="muted">静音</label>
86
+
87
+ <label>
88
+ <input id="room-mute" type="checkbox" v-model="muted">
89
+ 静音
90
+ </label>
91
+
98
92
  <button id="room-power" type="button" :aria-pressed="powered">电源</button>
93
+
99
94
  <select id="room-source" v-model="source">
100
95
  <option value="A">输入 A</option>
101
96
  <option value="B">输入 B</option>
102
97
  </select>
98
+
103
99
  <span id="room-status">{{ status }}</span>
104
100
  <meter id="room-meter" :value="meter" min="0" max="100" aria-label="实时信号电平"></meter>
105
101
  <progress id="room-progress" :value="progress" max="100" aria-label="进度"></progress>
106
102
  </section>
107
103
  </template>
104
+
108
105
  <script>
109
106
  export default {
110
107
  data () {
111
- return { volume: 0, meter: 0, muted: false, powered: false, source: 'A', status: '待机', progress: 0 }
108
+ return {
109
+ volume: 0,
110
+ muted: false,
111
+ powered: false,
112
+ source: 'A',
113
+ status: '待机',
114
+ meter: 0,
115
+ progress: 0
116
+ }
112
117
  }
113
118
  }
114
119
  </script>
115
120
  ```
116
121
 
117
- ### 4.2 Vuetify 控件
122
+ ### 3.2 `v-for` 列表绑定
123
+
124
+ 当前支持一层、来源为 `data()` 顶层数组字段的列表。对象列表使用稳定对象 ID:
125
+
126
+ ```html
127
+ <div v-for="item in items" :key="item.id">
128
+ <span>{{ item.name }}</span>
129
+ <input :id="'volume-' + item.id" type="range"
130
+ min="0" max="100" step="1" v-model.number="item.volume">
131
+ </div>
132
+ ```
133
+
134
+ 也支持带索引别名的对象列表:
135
+
136
+ ```html
137
+ <div v-for="(item, index) in items" :key="item.id">
138
+ <input id="row-volume" type="range" v-model.number="items[index].volume">
139
+ </div>
140
+ ```
141
+
142
+ 数组列表使用索引作为 key:
143
+
144
+ ```html
145
+ <input v-for="(value, index) in values" :key="index"
146
+ :id="'value-' + index" type="range" v-model.number="values[index]">
147
+ ```
148
+
149
+ 对象项可以使用静态嵌套字段,例如 `item.audio.volume` 或 `items[index].audio.volume`。数组项可以使用 `values[index]`。模型路径必须是标识符和静态点路径,不能调用函数、做计算或使用动态属性。
150
+
151
+ 一个列表项需要多个并列控件时,可使用内层 `<template v-for>`,但它必须是外层节点 Template 的子节点:
152
+
153
+ ```html
154
+ <template>
155
+ <template v-for="item in items" :key="item.id">
156
+ <input id="row-volume" type="range" v-model.number="item.volume">
157
+ <input id="row-balance" type="range" v-model.number="item.balance">
158
+ </template>
159
+ </template>
160
+ ```
161
+
162
+ 列表规则:
163
+
164
+ - 对象列表必须使用精确的 `:key="item.id"`;数组列表必须使用精确的 `:key="index"`。
165
+ - `item.id` 必须稳定且在当前列表中唯一。重复 key/ID 的实例不会被安全绑定;Vue 重渲染期间短暂出现的重复 DOM 会被节点暂时忽略,key 恢复唯一后自动重新关联,但页面不应依赖这种容错。
166
+ - 列表外的每个绑定控件都应有唯一的普通 `id` 或 `name`。列表内的重复实例可以使用同一个静态 `id`,节点通过列表 key 区分实例;不要为每一项拼接依赖运行时随机值的 ID。
167
+ - 列表控件在编辑页面中按实际渲染出的实例逐项选择。列表增加或删除后,应重新进入编辑器、补齐或移除对应映射,再 Apply。
168
+ - `v-for` 可以写在实际容器上,也可以写在内层 `<template>` 上。不要把 `v-for` 直接写在唯一的外层节点 Template 上。
169
+ - 不支持嵌套 `v-for`、动态列表来源、过滤或计算表达式、复杂 key、slot scope、自定义列表项事件和任意动态索引。
170
+
171
+ `v-if`、标签页切换、普通 DOM 重建和过渡造成的重新渲染可以使用。节点会在元素重新出现后恢复绑定;不要通过 `mounted`、`watch` 或定时器手动补绑或回写模型。
172
+
173
+ ### 3.3 Vuetify 控件
118
174
 
119
175
  ```html
120
176
  <template>
121
177
  <section>
122
- <v-slider id="room-volume" label="音量" v-model="volume" :min="0" :max="100" :step="1"></v-slider>
178
+ <v-slider id="room-volume" label="音量" v-model="volume"
179
+ :min="0" :max="100" :step="1"></v-slider>
123
180
  <v-switch id="room-mute" label="静音" v-model="muted"></v-switch>
124
181
  <v-select id="room-source" label="输入源" v-model="source" :items="sources"></v-select>
125
182
  <v-radio-group id="room-mode" label="模式" v-model="mode">
@@ -127,82 +184,132 @@ export default {
127
184
  <v-radio label="模式 B" value="B"></v-radio>
128
185
  </v-radio-group>
129
186
  <v-text-field id="room-title" label="名称" v-model="title"></v-text-field>
130
- <v-progress-linear id="room-meter" :model-value="meter" :max="100"></v-progress-linear>
187
+ <v-progress-linear id="room-meter" v-model="meter" :max="100"></v-progress-linear>
131
188
  <span id="room-status">{{ status }}</span>
132
189
  </section>
133
190
  </template>
191
+
134
192
  <script>
135
193
  export default {
136
194
  data () {
137
195
  return {
138
- volume: 0, muted: false, source: 'A', sources: ['A', 'B'],
139
- mode: 'A', title: '', meter: 0, status: '待机'
196
+ volume: 0,
197
+ muted: false,
198
+ sources: ['A', 'B'],
199
+ source: 'A',
200
+ mode: 'A',
201
+ title: '',
202
+ meter: 0,
203
+ status: '待机'
140
204
  }
141
205
  }
142
206
  }
143
207
  </script>
144
208
  ```
145
209
 
146
- 示例选项 A/B 只是占位数据,不保证与实际设备相符。部署人员应确认驱动的枚举值,再决定前端选项。节点不会自动生成或改写下拉菜单选项。
210
+ 当前 Vuetify 适配器包括 `v-slider`、`v-switch`、`v-checkbox`、`v-select`、`v-radio-group`、`v-text-field`、`v-textarea`、`v-progress-linear` 和 `v-progress-circular`。普通布局组件可以使用,但布局组件本身不是绑定目标。
147
211
 
148
- 图片明暗、数值旁显等效果可以正常依赖 `volume` 或 `light` 做 Vue/CSS 展示。这些展示元素不发送设备命令,也不要反过来用 watch、mounted 或定时器重置正在绑定的字段。
212
+ 以下写法当前不支持:
149
213
 
150
- ## 5. 部署与绑定
214
+ - `v-btn` 作为自动设备按钮;需要布尔设备控制时使用语义按钮写法。
215
+ - `v-range-slider`、多选、`return-object`、对象模型、数组模型、自定义 `true-value`/`false-value`。
216
+ - `v-autocomplete`、`v-combobox`、原生 radio 和带独立事件处理器的子控件。
217
+ - 动态量程;range 的 `min`、`max`、`step` 必须是数字字面量或数字字面量绑定。`v-slider` 必须满足 min < max、step > 0。
218
+ - `v-progress-linear` 的 `min`/`step`、`indeterminate`、`clickable`;`v-progress-circular` 的 `min`/`max`/`step`、`indeterminate`。前者按 0 到 max 工作,后者固定按 0 到 100 工作。
151
219
 
152
- 1. 将页面粘贴到 `ui-control-panel` Template,选择 Dashboard 页面,设置 Room ID、普通账号及独立的编辑账号。普通账号密码至少 12 个字符;账号密码由节点配置,不能写入 H5。保存并 Deploy。
153
- 2. 重新打开节点,点击 `Open binding editor`,用编辑账号登录。Select 默认激活,可以点击、右键或触屏长按可绑定控件。`Open page` 打开普通控制页面,使用普通用户账号登录。
154
- 3. 选择在线设备、模式、Write point、Feedback point、编号方式,保存草稿。草稿和亮紫色边框不表示已部署;边框只在编辑模式显示。
155
- 4. 点 Apply。节点检查当前模板、目录参数和 Flow 版本,备份后替换该面板此前生成的桥接并保存。Apply 不替用户 Deploy;当 Automator 报告外部 Flow 变化时,编辑器只刷新一次 Flow 页面,之后由用户检查并手动 Deploy。不会无限追加旧桥接,也不会清除其他面板的绑定。Apply 成功后仍在编辑页,无需再次登录。
156
- 5. 点 Exit,回到普通页面登录页,再输入普通账号密码。普通账号不能进入编辑模式;不登录不能操作设备。进程重启或凭据变化可能要求重新登录。
220
+ 下拉选项可以是标量数组或普通 `{ title, value }` 选项,但模型必须是单个标量,且值必须与设备枚举值一致。示例中的 A/B 只是页面占位数据,节点不会自动生成或修改下拉选项。
157
221
 
158
- Bindings 列表可 Edit 或 Remove;这些操作先改草稿,最后统一 Apply。从列表进入 Edit,保存或取消后返回列表。刷新、离开或重启前先 Apply,避免草稿丢失。
222
+ ## 4. Meter 与普通数值反馈
159
223
 
160
- 普通 Deploy 不能替代网页 Apply。Template 改动可能使已保存控件失效;重新 Deploy 后检查绑定。Apply 的原有模板、目录、Flow 版本和输入校验保持不变;Flow 页面刷新不自动合并画布改动,也不自动 Deploy。
224
+ Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。页面作者只声明普通进度/电平控件;编辑人员把它绑定到带 Meter 元数据的 Feedback point 后,ControlPanel 自动完成批量订阅、同帧投影、页面侧量程换算、过期判断和重连。
161
225
 
162
- 设备必须在线且当前运行环境的 AMX 目录可返回参数,才能查询或重新 Apply 相关绑定。已保存映射在 Flow 中,启动和显示页面不要求驱动在线;但断线时不能保证设备控制或新反馈。Mac 安装模块不等于 MUSE 已安装,Push 也不等于复制本地 node_modules。部署模块属于运行环境操作,不属于前端开发职责。
226
+ - 一个 Meter 绑定只能使用 `read` 模式,不能配置 Write point。
227
+ - 只有驱动点位带有有效 `.metadata.meter`,并且是只读整数/浮点参数时,才会出现在 Meter 可选目标中。
228
+ - 普通 numeric `level`、`volume` 或其他连续参数不会因为名字像电平就自动变成 Meter。
229
+ - 一个面板最多支持 256 个 Meter 绑定。多个 Meter 会按流聚合,前端每帧一次性更新本页所有对应表头;页面不编写任何批处理或 socket 代码。
230
+ - Meter 需要独立字段,例如 `meter1`、`meter2`。不要与音量滑杆的 `volume` 共用字段,也不要让不同点位共用一个 Meter 模型。
231
+ - 断线、无效帧和过期帧由节点处理;页面看到的是最后值并带无效状态,页面作者不添加轮询或回写逻辑。
232
+ - 原生 `<meter>`/`<progress>` 默认页面范围是 0 到 1;Vuetify 进度组件分别使用固定的 0 到 max 或 0 到 100。应按页面显示范围声明固定量程。
163
233
 
164
- ## 6. 匹配、编号与范围
234
+ 量程适配发生在网页侧:页面量程与驱动量程之间做线性换算,反馈按相反方向换算,不修改驱动或协议。两端都必须是有限且 max > min 的数字;否则按原值传递,不能猜测量程。整数驱动值会取整,反馈会按页面 step 对齐。
235
+
236
+ 普通只读反馈使用文本展示即可:
237
+
238
+ ```html
239
+ <span id="room-status">{{ status }}</span>
240
+ <output id="room-value">{{ outputValue }}</output>
241
+ ```
165
242
 
166
- 点位筛选基于参数描述,而不是页面文字或驱动显式 `controlType`:
243
+ 文本展示只使用一个简单字段插值,例如 `{{ status }}` 或 `{{ outputValue }}`。不要使用 `toFixed()`、拼接表达式或多个插值作为绑定模型。
244
+
245
+ ## 5. 语义开关按钮
246
+
247
+ 普通文字 `<button>` 不会自动成为设备控件。需要布尔设备控制时使用标准语义,不添加事件:
248
+
249
+ ```html
250
+ <button id="room-power" type="button" :aria-pressed="powered">电源</button>
251
+ <button id="room-mute" type="button" role="switch" :aria-checked="muted">静音</button>
252
+ ```
253
+
254
+ 在 `data()` 中把 `powered` 和 `muted` 初始化为布尔值。也可以使用静态 `aria-pressed="false"` 或 `aria-checked="false"`,由节点更新状态。动态 ARIA 表达式只能是顶层字段,不能写取反、条件或函数。
255
+
256
+ ## 6. 绑定与编辑流程
257
+
258
+ 页面作者只需交付 Template。实际绑定由使用者在编辑页面完成:
259
+
260
+ 1. 将 Template 保存到 `ui-control-panel`,配置 Dashboard 页面、Room ID、普通登录账号和独立编辑账号,然后保存并 Deploy。
261
+ 2. 重新打开节点,点击 `Open binding editor`。编辑页登录使用编辑账号;进入后 Select 默认激活。`Open page` 打开普通控制页面,普通页面必须使用普通账号登录。
262
+ 3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
263
+ 4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API 更新当前面板的映射和受管理桥接。它不会导入、清除或追加其他面板的 Flow。
264
+ 5. Apply 成功后仍停留在编辑页;Bindings 列表可以 Edit 或 Remove,取消/保存后回到列表。Apply 后不应要求再次登录;点 Exit 才回到普通页面登录页。
265
+
266
+ Automator 的同步脚本只监听更新后的运行时 Flow 版本,并最多刷新一次 Flow 编辑器页面;它不读取、导入、合并、清除或再次提交 Flow。当前实现不判断画布是否有未 Deploy 改动,也不会合并画布草稿;在 Apply 前应先处理好 Automator 中未保存的画布改动。
267
+
268
+ Template 修改可能使已有映射失效。重新 Deploy 后,应重新打开绑定编辑器检查候选控件;列表增删、控件 ID/标签类型/模型写法改变后,也应重新检查并 Apply。普通页面登录账号不能进入编辑模式,未登录不能操作设备。
269
+
270
+ ## 7. 点位匹配与量程
271
+
272
+ 编辑器根据 AMX 参数描述推导可匹配类型,不根据页面文字,也不把驱动作者写入的旧式显式 `controlType` 当作页面契约。主要依据是 `.kind`、`.type`、`.access`、`.min`、`.max`、`.enums` 和 `.metadata`。Meter 的 `.metadata.meter` 是实时通道所需的特殊元数据,不能省略。
167
273
 
168
274
  | 参数描述 | 可匹配控件 |
169
275
  | --- | --- |
170
- | boolean,或无枚举且范围恰为 0–1 的 integer | 布尔开关;整数 0/1 由节点转换为前端布尔值。 |
171
- | 可写 integer/float 数值参数 | range;这类参数用于 volume/增益等普通连续设置。 |
172
- | 只读 integer/float,且没有 Meter 元数据 | 只读文本展示;不能选择为 Meter。 |
173
- | 只读 integer/float,且带 `.metadata.meter` | Meter、原生 `meter` 或 Vuetify 进度组件;只走实时 Meter 反馈。 |
174
- | enum,或带枚举描述的 integer/string | 单选枚举。当前整数路径以 `/source`、`/preset` 结尾也会按选择类处理;仍需确认合法选项。 |
175
- | string/dstring | 文本输入。 |
176
- | 上述类型的可读参数 | 简单文本展示也可绑定。 |
177
-
178
- 只有 `kind=param` 且具备相应 ro/wo/rw 权限的点位才参与绑定。范围未知不会通过“读取一个当前值”猜出量程;普通命令、数组或未知描述不保证适配。页面无需自己判断这些类型。
276
+ | boolean,或没有枚举且范围恰为 0 到 1 的 integer | 布尔开关;整数 0/1 会转换为前端布尔值。 |
277
+ | 可写 integer/float | range 或 `v-slider`;用于音量/增益等普通连续控制。 |
278
+ | 只读 integer/float,没有 Meter 元数据 | 文本展示等普通只读反馈,不能选择为 Meter。 |
279
+ | 只读 integer/float,带有效 `.metadata.meter` | Meter、原生 `meter`/`progress` 或 Vuetify 进度组件;只能读。 |
280
+ | enum,或带枚举描述的 integer/string | 单选枚举。 |
281
+ | 没有枚举但路径以 `/source` 或 `/preset` 结尾的 integer | 选择类参数,仍需确认合法选项。 |
282
+ | string/dstring | 文本输入或文本展示。 |
179
283
 
180
- Write point 改动时 Feedback point 跟随;单独改 Feedback point 不反向改 Write point。只读模式没有 Write point。编号选择默认 Zero-based;实际保存优先采用目录里的 `.onebased`,目录未提供时才采用用户选择。它不是对网页音量值加减 1,也不是驱动 `protocolChannel` 的编号。
284
+ 只有 `kind=param` 且具备相应读/写权限的点位参与绑定。普通命令、数组和未知描述不保证可绑定。
181
285
 
182
- 量程适配只发生在网页侧:有效的页面 [uiMin, uiMax] 与驱动 [driverMin, driverMax] 之间线性换算,反馈反向换算,不改驱动代码或协议。两边都必须是有限数字且 max > min;缺少有效量程时原值传递,不能假定自动缩放。整数设备目标会取整;回到网页时按页面 step 对齐。Write/Feedback 使用不同量程时分别换算,部署人员应确认二者属于同一逻辑控制量。
286
+ Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会反向改 Write point。Meter 没有 Write point。编号方式默认 Zero-based;如果目录点位带有 `.onebased`,保存时以目录描述为准,否则使用编辑页选择。编号方式只影响驱动点位编号,不会给网页值加减 1。
183
287
 
184
- ## 7. 排查顺序
288
+ ## 8. 常见排查
185
289
 
186
290
  | 现象 | 先检查 |
187
291
  | --- | --- |
188
- | 控件不能选中 | 是否在支持清单、是否有事件指令/复杂模型/v-for、自闭合 Vuetify、重复 ID;阅读编辑页给出的拒绝原因。 |
189
- | 有设备但点位为空 | 在线状态、参数目录、值类型与 ro/wo/rw 权限;不是所有设备命令都属于可绑定参数。 |
190
- | 草稿有紫框但普通页无效 | Apply 是否成功、模式和点位是否正确、桥接是否同步、MUSE 是否加载预期模块版本。 |
191
- | 页面一直 Loading | 先检查节点状态和运行日志中的模板编译错误,例如在原生 meter/progress 上使用带修饰符的 v-model、动态表达式或同时写 v-model 与 :value。简单顶层 v-model 会由节点转换为只读 :value;节点会显示具体模板错误。会话检查包含响应正文的超时保护,设备离线不应阻止普通页进入登录页。登录检查的时限不包含 Dashboard 资源下载和组件编译时间。 |
192
- | 滑杆动不了 | 是否仍在 Select 编辑模式、模型类型是否正确、是否存在手写回写逻辑;不要先改驱动。 |
193
- | 页面变化但设备不变 | 写映射和驱动收发日志。前端变化不证明命令到达设备。 |
194
- | 设备已变但页面旧值 | Feedback point、事件/读取是否有新反馈。节点不能凭写入成功制造真实设备反馈。 |
195
- | 原生与 Vuetify 表现不同 | 是否符合各自模型、事件和量程约束;不要给绑定控件额外增加发送监听。 |
196
-
197
- 隔离测试只验证软件逻辑,不能证明真实设备闭环、MUSE 网络或驱动版本都正确。
198
-
199
- ## 8. 给 AI 的交付检查
200
-
201
- - 输出可粘贴的 Template 组件片段,不输出 Node-RED Flow、设备路径、绑定 JSON 或账号密码。
202
- - 所有待绑定控件位于支持清单,使用稳定唯一 ID;数据模型简单、独立且类型正确。
203
- - 页面量程明确、数值常量有效;下拉菜单选项与实际设备值对应。
204
- - 没有手写 `data-cp-*`、旧发送接口、设备通信、绑定控件事件或复杂模型。
205
- - 实时表头仍是普通 HTML/Vue/Vuetify;没有 stream、socket、订阅、帧协议或驱动相关代码。
206
- - Vuetify 标签成对闭合;不把自动绑定目标放入 v-for。
207
- - 保留正常的视觉与纯前端交互;不为了绑定重写驱动或添加第二套控制逻辑。
208
- - 不声称任意普通页面都可无条件自动绑定;不支持的组件要明确说明,不能伪造兼容写法。
292
+ | 控件不能选择 | 是否在支持清单;是否有事件指令、复杂模型、动态量程、重复/不唯一 key、重复 ID 或自闭合 Vuetify 标签。 |
293
+ | `v-for` 实例没有出现 | 来源是否是顶层数组;key 是否严格符合对象/数组规则;列表项是否实际渲染且 key 唯一。 |
294
+ | 设备有了但点位为空 | 设备在线状态、AMX 参数目录、`kind/type/access` 和读写权限;不是所有命令都属于可绑定参数。 |
295
+ | Meter 可以选但无值 | 点位是否带 `.metadata.meter`;驱动是否实际发布 manifest 和 frame;页面字段是否与普通 Volume 分开。 |
296
+ | 页面一直 Loading | 查看模板编译错误,重点检查原生 Meter/Progress 的 v-model 写法、自闭合 Vuetify、动态表达式和不支持的组件。设备离线不应被页面作者通过轮询“修复”。 |
297
+ | 滑杆动不了 | 是否仍处于 Select 编辑模式;模型是否为数字;是否给绑定控件添加了自己的事件或回写逻辑。 |
298
+ | 页面变化但设备不变 | 检查映射的 Write point、设备权限和运行日志。前端模型变化不等于设备已确认执行。 |
299
+ | 设备值变了但页面旧 | 检查 Feedback point、普通反馈事件或 Meter frame 是否到达;节点不会凭写入成功伪造真实反馈。 |
300
+ | Apply 失败并提示 Flow changed | 先处理 Automator 中的未 Deploy 改动,再刷新编辑器并重新打开绑定页;不要手工复制或追加生成桥接。 |
301
+
302
+ 隔离测试只能证明模板识别、映射和软件通信逻辑,不能证明真实设备、MUSE 网络或驱动版本已经完成闭环测试。
303
+
304
+ ## 9. 给 AI 的交付检查
305
+
306
+ - 输出可粘贴的 Template 片段,不输出 Node-RED Flow、设备路径、绑定 JSON、账号密码或 ControlPanel API 调用。
307
+ - 所有待绑定控件位于支持清单,模型字段简单、独立、可写且类型正确。
308
+ - 列表只使用本指南规定的 `v-for`、`:key` 和模型路径;对象 key 稳定唯一,数组 key 使用 index。
309
+ - 列表内的 Meter、Volume 和其他控件使用不同模型字段;重复实例依靠稳定 key 区分,不生成随机 ID。
310
+ - 页面量程明确、数值常量有效;枚举选项与实际设备值对应。
311
+ - 没有手写 `data-cp-*`、设备通信、轮询、反馈回写、绑定控件事件或第二套发送逻辑。
312
+ - 实时表头仍是普通 HTML/Vue/Vuetify 控件;没有 stream、socket、订阅、帧协议或驱动代码。
313
+ - Vuetify 标签成对闭合;不使用本指南明确列出的不支持组件和属性。
314
+ - 保留正常的视觉和纯前端交互;不要为了绑定修改驱动或猜测设备量程。
315
+ - 不声称任意普通页面都能无条件自动绑定;遇到不支持的组件或表达式,应改写为支持的普通写法。
package/README.md CHANGED
@@ -10,7 +10,7 @@ Both logins work over HTTP on a trusted local network. HTTPS is recommended for
10
10
 
11
11
  Bindings are stored in `visualBindings` on the node. The runtime reads that mapping to authenticate commands, route feedback, and generate a native bridge. The page author does not put device paths, `data-cp-*` attributes, or device-send code in the Template. Author-provided `data-cp-*` attributes are rejected.
12
12
 
13
- Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give repeated controls distinct standard IDs. Simple top-level Vue `v-model` works; controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
13
+ Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give repeated controls distinct standard IDs. Simple top-level Vue `v-model` works, including object-list models such as `item.volume`, `item.audio.volume`, or `items[index].volume`, primitive array models such as `values[index]`, and an inner `<template v-for>` for multiple controls per row. During Vue re-rendering, a temporary duplicate list key is ignored until the key becomes unique again, so bindings do not attach twice. Controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
14
14
 
15
15
  Realtime meter delivery does not change the page-authoring contract. Authors use ordinary `meter`, `progress`, `v-progress-linear`, or `v-progress-circular` controls with stable IDs and simple top-level fields. They do not declare devices, point paths, stream IDs, sockets, subscriptions, or frame formats. A `volume` binding is a writable gain setting with ordinary feedback; a `meter` binding is read-only realtime signal telemetry and requires a target that advertises Meter metadata. A normal numeric `level` target is not automatically a Meter. When the selected feedback point advertises realtime meter delivery, ControlPanel automatically shares the device stream, projects complete frames to the bound controls, applies webpage-side range conversion, and handles stale/reconnected data.
16
16
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.16",
3
+ "version": "1.9.0",
4
4
  "lockfileVersion": 2,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "node-red-contrib-room-tools",
9
- "version": "1.8.16",
9
+ "version": "1.9.0",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@vue/compiler-sfc": "3.5.42",
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.16",
3
+ "version": "1.9.0",
4
4
  "description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
- "engines": { "node": ">=18.16.0" },
6
+ "engines": {
7
+ "node": ">=18.16.0"
8
+ },
7
9
  "keywords": [
8
10
  "node-red"
9
11
  ],
@@ -107,25 +107,106 @@ function initializeVisualBinding () {
107
107
  }
108
108
  return String(node.value)
109
109
  }
110
+ function repeatMarkerFor (node, repeat) {
111
+ let current = node
112
+ while (current && current !== root) {
113
+ if (current.getAttribute?.('data-cp-repeat') === repeat?.locator) {
114
+ const key = current.getAttribute('data-cp-repeat-key')
115
+ return key == null ? null : String(key)
116
+ }
117
+ current = current.parentElement
118
+ }
119
+ return null
120
+ }
121
+ function bindingMatchesNode (node, binding) {
122
+ if (node.getAttribute('data-cp-auto') !== binding.locator) return false
123
+ if (!binding.repeat) return true
124
+ return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
125
+ }
126
+ function nodesForBinding (binding) {
127
+ return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
128
+ }
129
+ function nodeForBinding (binding) {
130
+ const matches = nodesForBinding(binding)
131
+ return matches.length === 1 ? matches[0] : null
132
+ }
133
+ function repeatItem (binding) {
134
+ const repeat = binding.repeat
135
+ const source = component[repeat.source]
136
+ if (!Array.isArray(source)) return undefined
137
+ if (repeat.kind === 'array') return source[Number(binding.instanceKey)]
138
+ const keyField = repeat.key.slice(repeat.value.length + 1)
139
+ return source.find(value => value && String(value[keyField]) === String(binding.instanceKey))
140
+ }
141
+ function readRepeatField (item, field) {
142
+ return field.split('.').reduce((value, name) => value == null ? undefined : value[name], item)
143
+ }
144
+ function writeRepeatField (item, field, value) {
145
+ const names = field.split('.')
146
+ const last = names.pop()
147
+ let target = item
148
+ for (const name of names) {
149
+ if (!target || typeof target !== 'object') return false
150
+ target = target[name]
151
+ }
152
+ if (!target || typeof target !== 'object') return false
153
+ if (!Object.is(target[last], value)) target[last] = value
154
+ return true
155
+ }
156
+ function repeatModelValue (binding) {
157
+ if (!binding.repeat || !binding.model) return binding.model ? component[binding.model] : undefined
158
+ const repeat = binding.repeat
159
+ const source = component[repeat.source]
160
+ if (!Array.isArray(source)) return undefined
161
+ const item = repeatItem(binding)
162
+ if (repeat.kind === 'array' && binding.model === `${repeat.source}[${repeat.index}]`) return item
163
+ const prefix = repeat.value + '.'
164
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
165
+ const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
166
+ : indexedPrefix && binding.model.startsWith(indexedPrefix)
167
+ ? binding.model.slice(indexedPrefix.length) : ''
168
+ return item && field ? readRepeatField(item, field) : undefined
169
+ }
170
+ function setRepeatModel (binding, value) {
171
+ if (!binding.repeat || !binding.model) return false
172
+ const repeat = binding.repeat
173
+ const source = component[repeat.source]
174
+ if (!Array.isArray(source)) return false
175
+ const item = repeatItem(binding)
176
+ const prefix = repeat.value + '.'
177
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
178
+ const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
179
+ : indexedPrefix && binding.model.startsWith(indexedPrefix)
180
+ ? binding.model.slice(indexedPrefix.length) : ''
181
+ if (field) {
182
+ return writeRepeatField(item, field, value)
183
+ }
184
+ if (repeat.kind !== 'array' || binding.model !== `${repeat.source}[${repeat.index}]`) return false
185
+ const index = Number(binding.instanceKey)
186
+ if (!Number.isInteger(index) || index < 0 || index >= source.length) return false
187
+ if (!Object.is(source[index], value)) source[index] = value
188
+ return true
189
+ }
110
190
  function showFeedback () {
111
191
  for (const [id, binding] of saved) {
112
192
  if (binding.mode === 'write' || binding.readMeta?.meter) continue
113
- const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
114
- if (matches.length !== 1) continue
193
+ const node = nodeForBinding(binding)
194
+ if (!node) continue
115
195
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
116
196
  const value = component.boundDisplayValue(id, component.boundState[id])
117
- const node = matches[0]
118
197
  const displayValue = ['range', 'meter'].includes(binding.adapter)
119
198
  ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
120
199
  numericRange(binding, node)) : value
121
200
  if (binding.model) {
122
201
  if (binding.component && binding.adapter === 'text-input' &&
123
202
  (pendingText.has(id) || node.contains?.(document.activeElement))) continue
124
- if (binding.model in component && !component.$options?.computed?.[binding.model]) {
125
- if (!Object.is(component[binding.model], displayValue)) {
203
+ const current = repeatModelValue(binding)
204
+ if (binding.repeat || (binding.model in component && !component.$options?.computed?.[binding.model])) {
205
+ if (!Object.is(current, displayValue)) {
126
206
  pendingText.delete(id)
127
207
  if (binding.component) feedbackModels.set(id, displayValue)
128
- component[binding.model] = displayValue
208
+ if (binding.repeat) setRepeatModel(binding, displayValue)
209
+ else component[binding.model] = displayValue
129
210
  if (binding.component && component.$nextTick) {
130
211
  void component.$nextTick(() => {
131
212
  if (Object.is(feedbackModels.get(id), displayValue)) feedbackModels.delete(id)
@@ -151,11 +232,12 @@ function initializeVisualBinding () {
151
232
  function attachControls () {
152
233
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
153
234
  if (attached.has(node)) continue
154
- attached.add(node)
155
235
  const locator = node.getAttribute('data-cp-auto')
156
- const matched = [...saved].find(([, entry]) => entry.locator === locator)
236
+ const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
157
237
  if (!matched) continue
158
238
  const [id, binding] = matched
239
+ if (nodeForBinding(binding) !== node) continue
240
+ attached.add(node)
159
241
  if (binding.mode === 'read') continue
160
242
  if (binding.component) {
161
243
  if (binding.adapter === 'text-input') {
@@ -198,14 +280,9 @@ function initializeVisualBinding () {
198
280
  } else node.addEventListener('change', () => send(true))
199
281
  }
200
282
  if (meterEntries.length) {
201
- const locators = new Map()
202
- for (const node of root.querySelectorAll('[data-cp-auto]')) {
203
- const locator = node.getAttribute('data-cp-auto')
204
- locators.set(locator, locators.has(locator) ? null : node)
205
- }
206
283
  meterTargets.clear()
207
284
  for (const [id, binding] of meterEntries) {
208
- const node = locators.get(binding.locator)
285
+ const node = nodeForBinding(binding)
209
286
  if (node) meterTargets.set(id, { node, binding })
210
287
  }
211
288
  if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
@@ -224,7 +301,8 @@ function initializeVisualBinding () {
224
301
  if (!frame.valid[index]) continue
225
302
  const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
226
303
  if (binding.model) {
227
- if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
304
+ if (binding.repeat) setRepeatModel(binding, value)
305
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
228
306
  } else node.value = value
229
307
  }
230
308
  }
@@ -271,7 +349,9 @@ function initializeVisualBinding () {
271
349
  console.error('ControlPanel binding command failed', error)
272
350
  }
273
351
  }
274
- const unwatch = component.$watch(binding.model, value => {
352
+ const unwatch = component.$watch(
353
+ binding.repeat ? () => repeatModelValue(binding) : binding.model,
354
+ value => {
275
355
  if (feedbackModels.has(id)) {
276
356
  const feedbackValue = feedbackModels.get(id)
277
357
  feedbackModels.delete(id)
@@ -287,21 +367,25 @@ function initializeVisualBinding () {
287
367
  clearTimeout(finishTimer)
288
368
  finishTimer = setTimeout(() => send(value, true), 160)
289
369
  } else send(value, true)
290
- })
370
+ })
291
371
  cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
292
372
  }
293
373
  }
294
374
  function closeOverlay () { overlay?.remove(); overlay = null }
375
+ function sameBindingTarget (entry, candidate) {
376
+ return entry.locator === candidate.locator &&
377
+ (!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
378
+ }
295
379
  function updateBindingMarkers () {
296
380
  for (const node of root.querySelectorAll('[data-cpv-bound="true"]')) {
297
381
  node.removeAttribute('data-cpv-bound')
298
382
  node.removeAttribute('data-cpv-binding-id')
299
383
  }
300
384
  if (!editing || !editSession) return
301
- const bindings = new Map(draft.map(entry => [entry.locator, entry]))
302
385
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
303
- const entry = bindings.get(node.getAttribute('data-cp-auto'))
386
+ const entry = draft.find(item => bindingMatchesNode(node, item))
304
387
  if (!entry) continue
388
+ if (nodeForBinding(entry) !== node) continue
305
389
  node.setAttribute('data-cpv-bound', 'true')
306
390
  node.setAttribute('data-cpv-binding-id', entry.id)
307
391
  }
@@ -310,10 +394,10 @@ function initializeVisualBinding () {
310
394
  closeOverlay()
311
395
  overlay = element('div', null, 'cpv-menu')
312
396
  overlay.setAttribute('role', 'menu')
313
- const existing = draft.find(entry => entry.locator === candidate.locator)
397
+ const existing = draft.find(entry => sameBindingTarget(entry, candidate))
314
398
  overlay.append(button(existing ? 'Edit binding' : 'Bind control', () => bindingDialog(candidate)))
315
399
  if (existing) overlay.append(button('Remove binding', () => {
316
- draft = draft.filter(entry => entry.locator !== candidate.locator)
400
+ draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
317
401
  updateBindingMarkers()
318
402
  closeOverlay()
319
403
  inform('Binding removed from draft')
@@ -457,14 +541,17 @@ function initializeVisualBinding () {
457
541
  }
458
542
  async function bindingDialog (candidate, options = {}) {
459
543
  const returnToBindings = options.returnToBindings === true
460
- const existing = draft.find(entry => entry.locator === candidate.locator)
544
+ const existing = draft.find(entry => sameBindingTarget(entry, candidate))
461
545
  const dialog = showDialog(existing ? 'Edit binding' : 'Bind control')
462
546
  const form = element('form')
463
547
  const name = field(form, 'Name', element('input'))
464
- name.value = existing?.label || candidate.label
548
+ name.value = existing?.label || (candidate.repeat ? `${candidate.label} [${candidate.instanceKey}]` : candidate.label)
465
549
  name.maxLength = 80
466
550
  const id = field(form, 'Control ID', element('input'))
467
- id.value = existing?.id || 'control.' + candidate.locator.slice(-12)
551
+ const instanceSuffix = candidate.repeat
552
+ ? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
553
+ : ''
554
+ id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
468
555
  const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
469
556
  ? ['read'] : ['both', 'write', 'read']
470
557
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
@@ -532,7 +619,7 @@ function initializeVisualBinding () {
532
619
  const actions = element('div', null, 'cpv-actions')
533
620
  actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
534
621
  if (existing) actions.append(button('Remove', () => {
535
- draft = draft.filter(entry => entry.locator !== candidate.locator)
622
+ draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
536
623
  finishBindingEdit(returnToBindings)
537
624
  inform('Binding removed from draft')
538
625
  }))
@@ -543,7 +630,7 @@ function initializeVisualBinding () {
543
630
  form.addEventListener('submit', event => {
544
631
  event.preventDefault()
545
632
  if (!/^[A-Za-z][A-Za-z0-9._/-]{0,127}$/.test(id.value) ||
546
- draft.some(entry => entry.id === id.value && entry.locator !== candidate.locator)) {
633
+ draft.some(entry => entry.id === id.value && !sameBindingTarget(entry, candidate))) {
547
634
  message.textContent = 'Control ID must be unique and valid'; return
548
635
  }
549
636
  if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
@@ -553,12 +640,13 @@ function initializeVisualBinding () {
553
640
  const entry = {
554
641
  id: id.value, locator: candidate.locator, adapter: candidate.adapter,
555
642
  ...(candidate.model ? { model: candidate.model } : {}),
643
+ ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
556
644
  label: name.value.trim(), mode: mode.value, device: device.value,
557
645
  writePath: mode.value === 'read' ? '' : write.value,
558
646
  readPath: mode.value === 'write' ? '' : read.value,
559
647
  onebased: numbering.value === 'true'
560
648
  }
561
- draft = draft.filter(item => item.locator !== candidate.locator)
649
+ draft = draft.filter(item => !sameBindingTarget(item, candidate))
562
650
  draft.push(entry)
563
651
  updateBindingMarkers()
564
652
  finishBindingEdit(returnToBindings)
@@ -612,6 +700,8 @@ function initializeVisualBinding () {
612
700
  const position = (order.get(a.locator) ?? Number.MAX_SAFE_INTEGER) -
613
701
  (order.get(b.locator) ?? Number.MAX_SAFE_INTEGER)
614
702
  if (position) return position
703
+ const instancePosition = String(a.instanceKey || '').localeCompare(String(b.instanceKey || ''))
704
+ if (instancePosition) return instancePosition
615
705
  return String(a.id).localeCompare(String(b.id))
616
706
  })
617
707
  }
@@ -626,10 +716,11 @@ function initializeVisualBinding () {
626
716
  details.append(element('span', path, 'cpv-binding-path'))
627
717
  row.append(details)
628
718
  const actions = element('div', null, 'cpv-binding-actions')
629
- const candidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
719
+ const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
720
+ const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
630
721
  const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
631
- edit.disabled = !candidate
632
- if (!candidate) edit.title = 'This control is no longer available on the page'
722
+ edit.disabled = !candidate || !nodeForBinding(entry)
723
+ if (edit.disabled) edit.title = 'This control is no longer available on the page'
633
724
  actions.append(edit)
634
725
  actions.append(button('Remove', () => {
635
726
  draft = draft.filter(item => item.id !== entry.id)
@@ -650,9 +741,17 @@ function initializeVisualBinding () {
650
741
  function candidateFromEvent (event) {
651
742
  const node = event.target?.closest?.('[data-cp-auto]')
652
743
  if (!node || !root.contains(node) || !snapshot) return null
653
- const candidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
654
- if (!candidate?.component || !candidate.model) return candidate
655
- const value = component[candidate.model]
744
+ const baseCandidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
745
+ if (!baseCandidate) return null
746
+ const instanceKey = baseCandidate.repeat ? repeatMarkerFor(node, baseCandidate.repeat) : undefined
747
+ const candidate = baseCandidate.repeat
748
+ ? { ...baseCandidate, ...(instanceKey == null ? {} : { instanceKey }) }
749
+ : baseCandidate
750
+ if (candidate.repeat && instanceKey == null) {
751
+ return { ...candidate, supported: false, reason: 'Repeated control instance key is unavailable' }
752
+ }
753
+ if (!candidate.component || !candidate.model) return candidate
754
+ const value = candidate.repeat ? repeatModelValue(candidate) : component[candidate.model]
656
755
  if (value == null) return candidate
657
756
  if (candidate.adapter === 'switch' && typeof value !== 'boolean') {
658
757
  return { ...candidate, supported: false, reason: 'This Vuetify switch needs a boolean v-model field' }
package/panel-meter.js CHANGED
@@ -188,7 +188,8 @@ function createPanelMeter ({ panelId, room, bindings, hub, onSubscribe = () => {
188
188
  subscribers.set(socket.id, { socket, active: entry.active, disconnect })
189
189
  socket.on('disconnect', disconnect)
190
190
  socket.emit('control-panel:meter-ready', layout)
191
- emitFrame(socket, hub.snapshot(panelId))
191
+ const initial = hub.snapshot(panelId)
192
+ emitFrame(socket, initial)
192
193
  onSubscribe()
193
194
  },
194
195
  'control-panel:meter-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }
@@ -8,6 +8,14 @@ const COMPONENT_ADAPTERS = Object.freeze({
8
8
  'v-text-field': 'text-input', 'v-textarea': 'text-input',
9
9
  'v-progress-linear': 'meter', 'v-progress-circular': 'meter'
10
10
  })
11
+ const SIMPLE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
12
+ const SIMPLE_PROPERTY_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/
13
+ const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'prototype', 'constructor'])
14
+
15
+ function safePropertyPath (value) {
16
+ return SIMPLE_PROPERTY_PATH.test(value) &&
17
+ value.split('.').every(part => !UNSAFE_PROPERTY_NAMES.has(part))
18
+ }
11
19
 
12
20
  function attribute (node, name) {
13
21
  const prop = (node.props || []).find(item => item.type === 6 && item.name === name)
@@ -78,42 +86,99 @@ function staticNumericProp (node, name) {
78
86
  return { value: numeric, dynamic: !Number.isFinite(numeric) }
79
87
  }
80
88
 
81
- function displayModel (node) {
89
+ function repeatModelInfo (expression, repeat) {
90
+ const model = String(expression || '').trim()
91
+ if (SIMPLE_IDENTIFIER.test(model)) return { valid: true, repeatModel: null }
92
+ if (!repeat?.valid) return { valid: false, repeatModel: null }
93
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
94
+ if (indexedPrefix && model.startsWith(indexedPrefix)) {
95
+ const field = model.slice(indexedPrefix.length)
96
+ if (safePropertyPath(field)) {
97
+ return { valid: true, repeatModel: { kind: 'source-index-property', field } }
98
+ }
99
+ }
100
+ if (repeat.kind === 'item') {
101
+ const escaped = repeat.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
102
+ const match = model.match(new RegExp('^' + escaped + '\\.' +
103
+ '(' + SIMPLE_PROPERTY_PATH.source.slice(1, -1) + ')$'))
104
+ return match && safePropertyPath(match[1])
105
+ ? { valid: true, repeatModel: { kind: 'item-property', field: match[1] } }
106
+ : { valid: false, repeatModel: null }
107
+ }
108
+ return model === `${repeat.source}[${repeat.index}]`
109
+ ? { valid: true, repeatModel: { kind: 'array-index' } }
110
+ : { valid: false, repeatModel: null }
111
+ }
112
+
113
+ function displayModel (node, repeat) {
82
114
  const dynamic = (node.children || []).filter(child => child.type === 5)
83
115
  if (!dynamic.length) return { model: '', valid: true }
84
116
  const expression = dynamic.length === 1 ? dynamic[0].content?.content?.trim() : ''
85
- return { model: expression, valid: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(expression) }
117
+ return { model: expression, ...repeatModelInfo(expression, repeat) }
86
118
  }
87
119
 
88
- function inputModel (node, allowNumber = false) {
120
+ function inputModel (node, allowNumber = false, repeat) {
89
121
  const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
90
122
  if (!directive) return { model: '', valid: true }
91
123
  const model = directive.exp?.content?.trim() || ''
92
124
  const modifiers = (directive.modifiers || []).map(item => typeof item === 'string' ? item : item.content)
93
- return { model, valid: !directive.arg && modifiers.every(item => allowNumber && item === 'number') &&
94
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
125
+ const info = repeatModelInfo(model, repeat)
126
+ return { model, repeatModel: info.repeatModel,
127
+ valid: !directive.arg && modifiers.every(item => allowNumber && item === 'number') && info.valid }
95
128
  }
96
129
 
97
- function progressModel (node) {
130
+ function progressModel (node, repeat) {
98
131
  const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
99
132
  ['model-value', 'modelValue'].includes(prop.arg?.content))
100
- if (!bound) return inputModel(node)
133
+ if (!bound) return inputModel(node, false, repeat)
101
134
  const model = bound.exp?.content?.trim() || ''
102
- return { model, valid: !hasDirective(node, ['model']) && !bound.modifiers?.length &&
103
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
135
+ const info = repeatModelInfo(model, repeat)
136
+ return { model, repeatModel: info.repeatModel,
137
+ valid: !hasDirective(node, ['model']) && !bound.modifiers?.length && info.valid }
104
138
  }
105
139
 
106
- function nativeMeterModel (node) {
140
+ function nativeMeterModel (node, repeat) {
107
141
  const modelDirective = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
108
142
  if (modelDirective) {
109
- return { model: modelDirective.exp?.content?.trim() || '', valid: !modelDirective.arg && !modelDirective.modifiers?.length &&
110
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(modelDirective.exp?.content?.trim() || '') }
143
+ const model = modelDirective.exp?.content?.trim() || ''
144
+ const info = repeatModelInfo(model, repeat)
145
+ return { model, repeatModel: info.repeatModel,
146
+ valid: !modelDirective.arg && !modelDirective.modifiers?.length && info.valid }
111
147
  }
112
148
  const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
113
149
  prop.arg?.content === 'value')
114
150
  if (!bound) return { model: '', valid: true }
115
151
  const model = bound.exp?.content?.trim() || ''
116
- return { model, valid: !bound.modifiers?.length && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
152
+ const info = repeatModelInfo(model, repeat)
153
+ return { model, repeatModel: info.repeatModel,
154
+ valid: !bound.modifiers?.length && info.valid }
155
+ }
156
+
157
+ function repeatDescriptor (node) {
158
+ const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'for')
159
+ if (!directive) return null
160
+ const parsed = directive.forParseResult
161
+ const source = parsed?.source?.content?.trim() || ''
162
+ const value = parsed?.value?.content?.trim() || ''
163
+ const index = parsed?.key?.content?.trim() || ''
164
+ const keyProp = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
165
+ prop.arg?.content === 'key' && !prop.modifiers?.length)
166
+ const key = keyProp?.exp?.content?.trim() || ''
167
+ const locator = `repeat:${hash(`${node.tag}:${node.loc.start.offset}:${directive.exp?.content || ''}`)}`
168
+ let kind = ''
169
+ let reason = ''
170
+ if (!SIMPLE_IDENTIFIER.test(source)) reason = 'v-for source must be one top-level array field'
171
+ else if (!SIMPLE_IDENTIFIER.test(value)) reason = 'v-for item alias must be a simple identifier'
172
+ else if (index && !SIMPLE_IDENTIFIER.test(index)) reason = 'v-for index alias must be a simple identifier'
173
+ else if (!key) reason = 'Repeated controls need a stable :key'
174
+ else if (key === `${value}.id`) kind = 'item'
175
+ else if (index && key === index) kind = 'array'
176
+ else reason = index
177
+ ? 'Object lists need :key="item.id" or array lists need :key="index"'
178
+ : 'Item v-for bindings require :key="item.id"'
179
+ return { locator, source, value, ...(index ? { index } : {}), key, kind,
180
+ markerOnControl: node.tag.toLowerCase() === 'template',
181
+ valid: !reason, ...(reason ? { reason } : {}) }
117
182
  }
118
183
 
119
184
  function normalizeMeterVModels (format) {
@@ -146,13 +211,14 @@ function normalizeMeterVModels (format) {
146
211
  return normalized
147
212
  }
148
213
 
149
- function pressedModel (node) {
214
+ function pressedModel (node, repeat) {
150
215
  const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
151
216
  ['aria-pressed', 'aria-checked'].includes(prop.arg?.content))
152
217
  if (!directive) return { model: '', valid: true }
153
218
  const model = directive.exp?.content?.trim() || ''
219
+ const info = repeatModelInfo(model, repeat)
154
220
  return { model, valid: !directive.modifiers?.length &&
155
- /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
221
+ info.valid, repeatModel: info.repeatModel }
156
222
  }
157
223
 
158
224
  function openingTagEnd (source, start) {
@@ -188,13 +254,22 @@ function inspectTemplate (format) {
188
254
  }
189
255
  const candidates = []
190
256
  const counts = new Map()
257
+ const repeatMarkers = []
191
258
 
192
- function visit (node, repeated = false) {
259
+ function visit (node, repeatContext = null) {
193
260
  if (node.type === 1) {
194
261
  if (node.tag.toLowerCase().startsWith('v-') && node.isSelfClosing) {
195
262
  throw new Error(`Vuetify component <${node.tag}> needs an explicit closing tag in Dashboard templates`)
196
263
  }
197
- const insideRepeat = repeated || hasDirective(node, ['for'])
264
+ const ownRepeat = repeatDescriptor(node)
265
+ const activeRepeat = ownRepeat
266
+ ? repeatContext ? { ...ownRepeat, valid: false, reason: 'Nested v-for bindings are not supported' } : ownRepeat
267
+ : repeatContext
268
+ const insideRepeat = Boolean(activeRepeat)
269
+ if (ownRepeat?.valid && !repeatContext && !ownRepeat.markerOnControl) {
270
+ repeatMarkers.push({ offset: openingTagEnd(format, node.loc.start.offset),
271
+ locator: ownRepeat.locator, key: ownRepeat.key })
272
+ }
198
273
  const adapter = candidateAdapter(node)
199
274
  const customAttribute = (node.props || []).some(prop =>
200
275
  (prop.type === 6 && prop.name.startsWith('data-cp-')) ||
@@ -203,12 +278,12 @@ function inspectTemplate (format) {
203
278
  if (adapter) {
204
279
  const component = COMPONENT_ADAPTERS[node.tag.toLowerCase()] ? node.tag.toLowerCase() : ''
205
280
  const display = adapter === 'text-display'
206
- ? displayModel(node)
207
- : ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node)
208
- : adapter === 'meter' && !component ? nativeMeterModel(node)
281
+ ? displayModel(node, activeRepeat)
282
+ : ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node, activeRepeat)
283
+ : adapter === 'meter' && !component ? nativeMeterModel(node, activeRepeat)
209
284
  : adapter === 'switch' && node.tag.toLowerCase() === 'button'
210
- ? pressedModel(node)
211
- : inputModel(node, adapter === 'range' || adapter === 'select')
285
+ ? pressedModel(node, activeRepeat)
286
+ : inputModel(node, adapter === 'range' || adapter === 'select', activeRepeat)
212
287
  const source = format.slice(node.loc.start.offset, openingTagEnd(format, node.loc.start.offset) + 1)
213
288
  const stableName = attribute(node, 'id') || attribute(node, 'name')
214
289
  const locator = stableName
@@ -252,6 +327,7 @@ function inspectTemplate (format) {
252
327
  const candidate = {
253
328
  locator, adapter,
254
329
  ...(component ? { component } : {}),
330
+ ...(activeRepeat ? { repeat: activeRepeat } : {}),
255
331
  label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
256
332
  stableName || display.model || (adapter === 'text-display' ? node.children[0]?.content?.trim() : '') || node.tag,
257
333
  ...(display.model ? { model: display.model } : {}),
@@ -267,12 +343,12 @@ function inspectTemplate (format) {
267
343
  } : {}),
268
344
  ...(attribute(node, 'data-unit') ? { unit: attribute(node, 'data-unit') } : {}),
269
345
  line: node.loc.start.line,
270
- supported: !insideRepeat && !explicitLogic && display.valid && !dynamicBounds &&
346
+ supported: (!insideRepeat || Boolean(activeRepeat.valid && display.valid)) && !explicitLogic && display.valid && !dynamicBounds &&
271
347
  !invalidSliderBounds &&
272
348
  !customSwitchValues && !unsupportedSelect && !unsupportedRadio &&
273
349
  !unsupportedTextType && !unsupportedProgress && !unsupportedCircular &&
274
350
  (!component || Boolean(display.model)),
275
- reason: insideRepeat ? 'Repeated controls need a stable item identity' :
351
+ reason: insideRepeat && !activeRepeat.valid ? activeRepeat.reason :
276
352
  explicitLogic ? 'Controls with existing handlers or reactive value bindings need a dedicated adapter' :
277
353
  !display.valid ? (adapter === 'text-display' ? 'Dynamic text needs one writable data field' :
278
354
  'Only a simple Vue v-model field is supported') :
@@ -289,11 +365,11 @@ function inspectTemplate (format) {
289
365
  candidates.push({ ...candidate, offset: openingTagEnd(format, node.loc.start.offset) })
290
366
  counts.set(locator, (counts.get(locator) || 0) + 1)
291
367
  }
292
- for (const child of node.children || []) visit(child, insideRepeat)
368
+ for (const child of node.children || []) visit(child, activeRepeat)
293
369
  return
294
370
  }
295
- for (const child of node.children || []) visit(child, repeated)
296
- for (const branch of node.branches || []) visit(branch, repeated)
371
+ for (const child of node.children || []) visit(child, repeatContext)
372
+ for (const branch of node.branches || []) visit(branch, repeatContext)
297
373
  }
298
374
 
299
375
  if (descriptor.template?.ast) visit(descriptor.template.ast)
@@ -303,17 +379,33 @@ function inspectTemplate (format) {
303
379
  candidate.reason = 'This element is ambiguous; give it a unique HTML id or name'
304
380
  }
305
381
  }
306
- return { candidates, templateHash: hash(format) }
382
+ return { candidates, repeatMarkers, templateHash: hash(format) }
307
383
  }
308
384
 
309
385
  function instrumentTemplate (format) {
310
386
  const inspected = inspectTemplate(format)
311
387
  let rendered = format
312
- for (const candidate of [...inspected.candidates].reverse()) {
313
- const before = rendered.slice(0, candidate.offset)
388
+ const insertions = new Map()
389
+ const addInsertion = (offset, value) => {
390
+ const list = insertions.get(offset) || []
391
+ list.push(value)
392
+ insertions.set(offset, list)
393
+ }
394
+ for (const candidate of inspected.candidates) {
395
+ addInsertion(candidate.offset, `data-cp-auto="${candidate.locator}"`)
396
+ if (candidate.repeat?.markerOnControl) {
397
+ addInsertion(candidate.offset,
398
+ `data-cp-repeat="${candidate.repeat.locator}" :data-cp-repeat-key="String(${candidate.repeat.key})"`)
399
+ }
400
+ }
401
+ for (const marker of inspected.repeatMarkers) {
402
+ addInsertion(marker.offset, `data-cp-repeat="${marker.locator}" :data-cp-repeat-key="String(${marker.key})"`)
403
+ }
404
+ for (const [offset, attributes] of [...insertions.entries()].sort((a, b) => b[0] - a[0])) {
405
+ const before = rendered.slice(0, offset)
314
406
  const selfClosing = /\/\s*$/.test(before)
315
- const insertAt = selfClosing ? before.lastIndexOf('/') : candidate.offset
316
- rendered = rendered.slice(0, insertAt) + ` data-cp-auto="${candidate.locator}"` + rendered.slice(insertAt)
407
+ const insertAt = selfClosing ? before.lastIndexOf('/') : offset
408
+ rendered = rendered.slice(0, insertAt) + ' ' + attributes.join(' ') + rendered.slice(insertAt)
317
409
  }
318
410
  return {
319
411
  format: normalizeMeterVModels(rendered),
@@ -3,8 +3,20 @@ const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./contr
3
3
  const { meterRoute } = require('./meter-route')
4
4
 
5
5
  const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
6
+ const INSTANCE_KEY = /^[A-Za-z0-9._:/-]{1,160}$/
6
7
  const MODES = new Set(['read', 'write', 'both'])
7
8
 
9
+ function targetKey (candidate, entry) {
10
+ if (!candidate.repeat) return candidate.locator
11
+ if (typeof entry.instanceKey !== 'string' || !INSTANCE_KEY.test(entry.instanceKey)) {
12
+ throw new Error('Repeated visual controls need a stable instance key')
13
+ }
14
+ if (candidate.repeat.kind === 'array' && !/^(0|[1-9][0-9]*)$/.test(entry.instanceKey)) {
15
+ throw new Error('Array repeated controls need a non-negative integer instance key')
16
+ }
17
+ return `${candidate.locator}#${entry.instanceKey}`
18
+ }
19
+
8
20
  function parseVisualBindings (config) {
9
21
  let raw = config.visualBindings || '{}'
10
22
  if (typeof raw === 'string') raw = JSON.parse(raw)
@@ -26,8 +38,9 @@ function visualRoutes (config) {
26
38
  if (!candidate || candidate.adapter !== entry.adapter) {
27
39
  throw new Error(`Visual control moved or became ambiguous: ${id}`)
28
40
  }
29
- if (usedLocators.has(entry.locator)) throw new Error(`Duplicate visual control locator: ${id}`)
30
- usedLocators.add(entry.locator)
41
+ const bindingKey = targetKey(candidate, entry)
42
+ if (usedLocators.has(bindingKey)) throw new Error(`Duplicate visual control locator: ${id}`)
43
+ usedLocators.add(bindingKey)
31
44
  if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
32
45
  (['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
33
46
  throw new Error(`Invalid visual binding settings: ${id}`)
@@ -41,7 +54,8 @@ function visualRoutes (config) {
41
54
  }
42
55
  routes.set(id, {
43
56
  control: { id, type: entry.type, mode: entry.mode, label: entry.label,
44
- adapter: entry.adapter, locator: entry.locator },
57
+ adapter: entry.adapter, locator: entry.locator,
58
+ ...(candidate.repeat ? { instanceKey: entry.instanceKey } : {}) },
45
59
  binding: entry
46
60
  })
47
61
  }
@@ -62,8 +76,9 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
62
76
  used.add(raw.id)
63
77
  const candidate = candidates.get(raw.locator)
64
78
  if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
65
- if (usedLocators.has(raw.locator)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
66
- usedLocators.add(raw.locator)
79
+ const bindingKey = targetKey(candidate, raw)
80
+ if (usedLocators.has(bindingKey)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
81
+ usedLocators.add(bindingKey)
67
82
  if (!MODES.has(raw.mode) || (['meter', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
68
83
  throw new Error(`Invalid binding mode: ${raw.id}`)
69
84
  }
@@ -91,15 +106,17 @@ function validatedVisualBindings ({ format, entries, devices, targetsByDevice })
91
106
  }
92
107
  if (candidate.model) {
93
108
  const identity = JSON.stringify([raw.device, read?.path, write?.path])
94
- const previous = models.get(candidate.model)
109
+ const modelKey = candidate.repeat ? `${candidate.model}#${raw.instanceKey}` : candidate.model
110
+ const previous = models.get(modelKey)
95
111
  if (previous && (read?.meter || previous.meter) && previous.identity !== identity) {
96
112
  throw Error(`Meter model "${candidate.model}" is already used by another binding; use a separate Vue model for the meter. Meter and volume controls must not share a model`)
97
113
  }
98
- models.set(candidate.model, { identity, meter: Boolean(read?.meter) })
114
+ models.set(modelKey, { identity, meter: Boolean(read?.meter) })
99
115
  }
100
116
  result[raw.id] = {
101
117
  locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
102
118
  ...(candidate.model ? { model: candidate.model } : {}),
119
+ ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: raw.instanceKey } : {}),
103
120
  ...(candidate.component ? { component: candidate.component } : {}),
104
121
  ...(['range', 'meter'].includes(raw.adapter)
105
122
  ? { uiRange: { min: candidate.min, max: candidate.max, step: candidate.step } } : {}),