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.
- package/CONTROL_PANEL_AI_GUIDE.md +222 -115
- package/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/panel-binding-editor.js +132 -33
- package/panel-meter.js +2 -1
- package/panel-template-instrumentation.js +124 -32
- package/panel-visual-bindings.js +24 -7
|
@@ -1,125 +1,182 @@
|
|
|
1
1
|
# ControlPanel 前端页面编写指南
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.8.16`、Dashboard 2 和 Vue Options API。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
本文件是给页面编写 AI 的实现契约,不是完整的 Vue 或 Vuetify 手册。目标是:页面作者只写普通 HTML、CSS 和 Vue 页面;设备、驱动、映射表、反馈订阅和通信细节全部由 `ControlPanel` 节点处理。
|
|
6
6
|
|
|
7
|
-
## 1.
|
|
7
|
+
## 1. 最重要的规则
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
页面作者只需要:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- 按普通 H5/Vue 方式写布局、样式、导航、控件和页面本地状态。
|
|
12
|
+
- 为需要绑定的控件准备简单、可写、类型正确的 Vue 数据字段。
|
|
13
|
+
- 使用稳定的普通 HTML `id` 或 `name`,并为重复列表使用稳定的 `:key`。
|
|
14
|
+
- 用 `volume`、`muted`、`source`、`meter` 等字段表达页面语义。字段名只是页面模型名,不需要使用任何内部 Control ID。
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
页面作者不需要知道:
|
|
14
17
|
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- 为修复反馈而添加设备轮询、强制回写滑杆、重复发送监听器或设备状态定时器。
|
|
18
|
+
- 驱动协议、设备路径、编号方式或 `program.json`。
|
|
19
|
+
- ControlPanel 的映射 JSON、桥接节点、stream、socket、SSE、帧格式或订阅主题。
|
|
20
|
+
- 内部 `data-cp-*` 标记、ControlPanel HTTP API 或设备发送接口。
|
|
21
|
+
- 量程换算、连续值节流、latest-wins、命令队列和反馈重连。
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
不要在页面里实现第二套设备通信。不要写设备轮询、定时回写滑杆、反馈回写命令、重复发送监听器或自定义发送函数。未绑定的控件不会自动控制设备。
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
页面里的 `volume` 和 `meter` 是两个不同语义:
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
- `volume` 等普通数值字段用于音量/增益等可写连续控制,并可接收普通真实值反馈。
|
|
28
|
+
- `meter` 等字段用于只读实时信号电平,只能接收驱动声明了 Meter 元数据的点位。
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
不要把同一个模型字段同时用于不同设备点位,尤其不要让 Meter 和 Volume 共用一个模型。
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
## 2. 页面格式与 Vue 约束
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
交付可以直接粘贴到节点 Template 的组件片段:一个外层 `<template>`,可选的 `<script>` 和 `<style>`。不要交付带 `<html>`、`<head>`、`<body>` 的完整文档,也不要交付需要外部构建才能运行的项目。
|
|
32
35
|
|
|
33
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
```js
|
|
39
|
+
export default {
|
|
40
|
+
data () {
|
|
41
|
+
return { volume: 0, muted: false, status: '待机' }
|
|
42
|
+
},
|
|
43
|
+
computed: {},
|
|
44
|
+
methods: {}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
57
47
|
|
|
58
|
-
|
|
48
|
+
不要使用 `<script setup>`、组件导入、自行创建 Vue 应用或外部运行时依赖。普通绑定模型应是 `data()` 中的顶层字段,或本指南 3.2 节规定的列表模型。计算属性、函数调用、取反、条件表达式和动态索引不能作为绑定模型。
|
|
59
49
|
|
|
60
|
-
|
|
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
|
-
|
|
52
|
+
不要给绑定控件添加 `@click`、`@input`、`@change`、`@update:model-value` 等事件指令,也不要额外绑定 `:value`、`:checked` 或 `:model-value`。节点需要独占这些事件和模型更新。页面导航、标签切换、纯视觉交互等事件可以放在不绑定设备的独立控件上。
|
|
70
53
|
|
|
71
|
-
|
|
54
|
+
以下是两个例外:
|
|
72
55
|
|
|
73
|
-
|
|
56
|
+
- 只读原生 `<meter>`/`<progress>` 可以使用 `:value`。
|
|
57
|
+
- 只读 `v-progress-linear`/`v-progress-circular` 可以使用简单 `v-model` 或 `:model-value`。
|
|
58
|
+
- 语义开关按钮可以使用简单的 `:aria-pressed` 或 `:aria-checked`。
|
|
74
59
|
|
|
75
|
-
|
|
60
|
+
所有 Vuetify 组件标签都成对闭合,例如 `<v-icon></v-icon>`。Dashboard 的模板渲染可能吞掉自闭合组件后面的内容,节点会在部署前拒绝 `<v-slider />`、`<v-progress-linear />` 等写法。原生 HTML void 元素,如 `<input>` 和 `<img>`,不受此限制。
|
|
76
61
|
|
|
77
|
-
|
|
62
|
+
## 3. 可绑定控件
|
|
78
63
|
|
|
79
|
-
|
|
64
|
+
以下是当前适配器支持的范围,不代表任意 Vuetify 或第三方组件都支持。
|
|
80
65
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
初始化值应匹配控件类型:数值为数字,开关为布尔值,文本和枚举为字符串。不要用数组、对象或 `null` 作为普通绑定控件的占位值。
|
|
89
78
|
|
|
90
|
-
###
|
|
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
|
-
|
|
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 {
|
|
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
|
-
###
|
|
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"
|
|
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"
|
|
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,
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
以下写法当前不支持:
|
|
149
213
|
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
## 4. Meter 与普通数值反馈
|
|
159
223
|
|
|
160
|
-
|
|
224
|
+
Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。页面作者只声明普通进度/电平控件;编辑人员把它绑定到带 Meter 元数据的 Feedback point 后,ControlPanel 自动完成批量订阅、同帧投影、页面侧量程换算、过期判断和重连。
|
|
161
225
|
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
| 可写 integer/float
|
|
172
|
-
| 只读 integer/float
|
|
173
|
-
| 只读 integer/float
|
|
174
|
-
| enum,或带枚举描述的 integer/string |
|
|
175
|
-
|
|
|
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
|
-
|
|
284
|
+
只有 `kind=param` 且具备相应读/写权限的点位参与绑定。普通命令、数组和未知描述不保证可绑定。
|
|
181
285
|
|
|
182
|
-
|
|
286
|
+
Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会反向改 Write point。Meter 没有 Write point。编号方式默认 Zero-based;如果目录点位带有 `.onebased`,保存时以目录描述为准,否则使用编辑页选择。编号方式只影响驱动点位编号,不会给网页值加减 1。
|
|
183
287
|
|
|
184
|
-
##
|
|
288
|
+
## 8. 常见排查
|
|
185
289
|
|
|
186
290
|
| 现象 | 先检查 |
|
|
187
291
|
| --- | --- |
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
|
193
|
-
|
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
-
|
|
203
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
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
|
|
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
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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": {
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.16.0"
|
|
8
|
+
},
|
|
7
9
|
"keywords": [
|
|
8
10
|
"node-red"
|
|
9
11
|
],
|
package/panel-binding-editor.js
CHANGED
|
@@ -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
|
|
114
|
-
if (
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
654
|
-
if (!
|
|
655
|
-
const
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
368
|
+
for (const child of node.children || []) visit(child, activeRepeat)
|
|
293
369
|
return
|
|
294
370
|
}
|
|
295
|
-
for (const child of node.children || []) visit(child,
|
|
296
|
-
for (const branch of node.branches || []) visit(branch,
|
|
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
|
-
|
|
313
|
-
|
|
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('/') :
|
|
316
|
-
rendered = rendered.slice(0, 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),
|
package/panel-visual-bindings.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
usedLocators.
|
|
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
|
-
|
|
66
|
-
usedLocators.
|
|
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
|
|
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(
|
|
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 } } : {}),
|