agentworkshop 0.7.21 → 0.7.23

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.
Files changed (105) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/BJbhwS5o.js +1 -0
  3. package/.output/public/_nuxt/Bo8t5fkJ.js +1 -0
  4. package/.output/public/_nuxt/{DBXhDLgl.js → BuiGDfMB.js} +1 -1
  5. package/.output/public/_nuxt/{QT54MQV9.js → C53drffV.js} +1 -1
  6. package/.output/public/_nuxt/{BA3XzwiD.js → CEHmSufQ.js} +2 -2
  7. package/.output/public/_nuxt/Dc32T41C.js +1 -0
  8. package/.output/public/_nuxt/DtzZqC0z.js +1 -0
  9. package/.output/public/_nuxt/{BOJaHO5V.js → Ko9mMsJj.js} +3 -3
  10. package/.output/public/_nuxt/{TownView.BHLKAjEu.css → TownView.zu2rU5nS.css} +1 -1
  11. package/.output/public/_nuxt/_id_.C-YXjn8Q.css +1 -0
  12. package/.output/public/_nuxt/_id_.MbzdFygI.css +1 -0
  13. package/.output/public/_nuxt/_wsId_.BqWBlY7X.css +1 -0
  14. package/.output/public/_nuxt/builds/latest.json +1 -1
  15. package/.output/public/_nuxt/builds/meta/1adc96e0-8bbd-4a79-ac7e-b6952556fef8.json +1 -0
  16. package/.output/public/_nuxt/daq.3OKj58HH.css +1 -0
  17. package/.output/public/_nuxt/dbi5bHkY.js +2 -0
  18. package/.output/public/_nuxt/dcw.rXH2fc2Z.css +1 -0
  19. package/.output/public/_nuxt/entry.OR_psZHp.css +1 -0
  20. package/.output/public/_nuxt/{DsOkkMX3.js → jyQyulh-.js} +1 -1
  21. package/.output/public/_nuxt/logs.DMOM-iHe.css +1 -0
  22. package/.output/public/_nuxt/{B-E8fXAO.js → n3vMFfrv.js} +1 -1
  23. package/.output/server/chunks/_/nitro.mjs +842 -717
  24. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  25. package/.output/server/chunks/build/{TownView-ql2eHZOg.mjs → TownView-DxeQe1Bl.mjs} +144 -143
  26. package/.output/server/chunks/build/{TownView-ql2eHZOg.mjs.map → TownView-DxeQe1Bl.mjs.map} +1 -1
  27. package/.output/server/chunks/build/{_id_-CwR5oLu9.mjs → _id_-DQDqj-cZ.mjs} +33 -35
  28. package/.output/server/chunks/build/_id_-DQDqj-cZ.mjs.map +1 -0
  29. package/.output/server/chunks/build/{_id_-Bs-euJPu.mjs → _id_-DY6v6sqB.mjs} +138 -137
  30. package/.output/server/chunks/build/_id_-DY6v6sqB.mjs.map +1 -0
  31. package/.output/server/chunks/build/{_wsId_-B3aB9bIp.mjs → _wsId_-C2O5zZsI.mjs} +6 -6
  32. package/.output/server/chunks/build/{_wsId_-B3aB9bIp.mjs.map → _wsId_-C2O5zZsI.mjs.map} +1 -1
  33. package/.output/server/chunks/build/{daq-epWL_sC7.mjs → daq-B-3xCp_2.mjs} +146 -118
  34. package/.output/server/chunks/build/daq-B-3xCp_2.mjs.map +1 -0
  35. package/.output/server/chunks/build/{dcw-CBPaFj2f.mjs → dcw-BL2dhuz7.mjs} +43 -43
  36. package/.output/server/chunks/build/{dcw-CBPaFj2f.mjs.map → dcw-BL2dhuz7.mjs.map} +1 -1
  37. package/.output/server/chunks/build/{logs-Bz1p6x_5.mjs → logs-Dto_umFY.mjs} +44 -38
  38. package/.output/server/chunks/build/logs-Dto_umFY.mjs.map +1 -0
  39. package/.output/server/chunks/build/{pages-Dq7_H4FK.mjs → pages-D9tOoJsf.mjs} +2 -2
  40. package/.output/server/chunks/build/{pages-Dq7_H4FK.mjs.map → pages-D9tOoJsf.mjs.map} +1 -1
  41. package/.output/server/chunks/build/{town-CstiKxf_.mjs → town-Dsh0jlwZ.mjs} +3 -3
  42. package/.output/server/chunks/build/{town-CstiKxf_.mjs.map → town-Dsh0jlwZ.mjs.map} +1 -1
  43. package/.output/server/chunks/build/{useDaqStream-BOOZ-p-l.mjs → useDaqStream-DkRL3tEW.mjs} +9 -1
  44. package/.output/server/chunks/build/{useDaqStream-BOOZ-p-l.mjs.map → useDaqStream-DkRL3tEW.mjs.map} +1 -1
  45. package/.output/server/chunks/routes/api/workshop/daq/_id/bind.post.mjs +1 -1
  46. package/.output/server/chunks/routes/api/workshop/daq/_id/bind.post.mjs.map +1 -1
  47. package/.output/server/chunks/routes/api/workshop/daq/_id/bindings.put.mjs +34 -0
  48. package/.output/server/chunks/routes/api/workshop/daq/_id/bindings.put.mjs.map +1 -0
  49. package/.output/server/chunks/routes/api/workshop/dcw/_id/bind.post.mjs +1 -1
  50. package/.output/server/chunks/routes/api/workshop/dcw/_id/bind.post.mjs.map +1 -1
  51. package/.output/server/chunks/routes/api/workshop/dcw/_id/bindings.put.mjs +33 -0
  52. package/.output/server/chunks/routes/api/workshop/dcw/_id/bindings.put.mjs.map +1 -0
  53. package/.output/server/chunks/routes/renderer.mjs +1 -1
  54. package/.output/server/chunks/virtual/entry.mjs +16 -8
  55. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  56. package/.output/server/package.json +1 -1
  57. package/app/assets/css/main.css +306 -161
  58. package/app/components/workshop/blocks/ClusterArtifact.vue +0 -16
  59. package/app/components/workshop/town/TownView.vue +7082 -7076
  60. package/app/composables/workshop/useDaqStream.ts +10 -0
  61. package/app/pages/daq/[id].vue +919 -937
  62. package/app/pages/daq/index.vue +67 -43
  63. package/app/pages/dcw/[id].vue +6 -5
  64. package/app/pages/dcw/index.vue +1 -30
  65. package/app/pages/logs/index.vue +99 -87
  66. package/i18n/locales/en.ts +3 -0
  67. package/i18n/locales/zh-CN.ts +5 -0
  68. package/package.json +148 -148
  69. package/scripts/_dbg-device-bind-e2e.mjs +129 -0
  70. package/scripts/_dbg-logs-ui-e2e.py +125 -0
  71. package/server/api/workshop/daq/[id]/bind.post.ts +5 -2
  72. package/server/api/workshop/daq/[id]/bindings.put.ts +22 -0
  73. package/server/api/workshop/dcw/[id]/bind.post.ts +4 -2
  74. package/server/api/workshop/dcw/[id]/bindings.put.ts +21 -0
  75. package/server/data/daqs.json +405 -100
  76. package/server/data/dcw-lines.json +7 -0
  77. package/server/data/dcw-rollback.json +21 -10
  78. package/server/data/dcw-runs.json +50 -54
  79. package/server/data/dcw-writes.json +24 -0
  80. package/server/data/dcws.json +505 -216
  81. package/server/data/line-runs.json +18 -18
  82. package/server/services/workshop/agents/industrial-context.ts +231 -222
  83. package/server/services/workshop/daq/daq-controller.ts +76 -23
  84. package/server/services/workshop/daq/daq-node.ts +11 -1
  85. package/server/services/workshop/dcw/dcw-controller.ts +50 -2
  86. package/server/services/workshop/dcw/dcw-node.ts +10 -1
  87. package/shared/daq-protocol.ts +2 -0
  88. package/shared/dcw-protocol.ts +2 -0
  89. package/.output/public/_nuxt/B_C3GLsD.js +0 -1
  90. package/.output/public/_nuxt/Ba7YofCb.js +0 -2
  91. package/.output/public/_nuxt/C_2bTK0C.js +0 -1
  92. package/.output/public/_nuxt/CgT5wMfz.js +0 -1
  93. package/.output/public/_nuxt/D4CLp1hc.js +0 -1
  94. package/.output/public/_nuxt/_id_.DIxEKj88.css +0 -1
  95. package/.output/public/_nuxt/_id_.IbmpkhH4.css +0 -1
  96. package/.output/public/_nuxt/_wsId_.BFyCuUwS.css +0 -1
  97. package/.output/public/_nuxt/builds/meta/5d344836-03a5-4f47-a0a7-fd30192f5a8f.json +0 -1
  98. package/.output/public/_nuxt/daq.6IrhVVfy.css +0 -1
  99. package/.output/public/_nuxt/dcw.rpqpOYQ8.css +0 -1
  100. package/.output/public/_nuxt/entry.DRUzDwmX.css +0 -1
  101. package/.output/public/_nuxt/logs.BarkrvUp.css +0 -1
  102. package/.output/server/chunks/build/_id_-Bs-euJPu.mjs.map +0 -1
  103. package/.output/server/chunks/build/_id_-CwR5oLu9.mjs.map +0 -1
  104. package/.output/server/chunks/build/daq-epWL_sC7.mjs.map +0 -1
  105. package/.output/server/chunks/build/logs-Bz1p6x_5.mjs.map +0 -1
@@ -1,937 +1,919 @@
1
- <script setup lang="ts">
2
- /**
3
- * 数采节点控制台(/daq/[id])—— 单节点专业控制面。
4
- * 实时值 + live 趋势(WS 流);参数控制(server DaqNode 单点参数:启停/周期/
5
- * 驱动/量程/预警带);设备绑定;时序库历史查询(降采样桶选择)。
6
- */
7
- import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
8
- import { useRoute } from 'vue-router'
9
- import { useDcwStream } from '@/app/composables/workshop/useDcwStream'
10
- import { useDaqStream, type DaqFrameLive } from '@/app/composables/workshop/useDaqStream'
11
- import { useDeviceTwins } from '@/app/composables/workshop/useDeviceTwins'
12
- import { daqKeyFromRef, DAQ_DRIVERS, type DaqDriverKind, type DaqNodeState, type DriverConfigField, type DriverTestResult as DaqDriverTestResult } from '#shared/daq-protocol'
13
-
14
- const { t } = useI18n()
15
-
16
- definePageMeta({ layout: 'default' })
17
-
18
- const route = useRoute()
19
- const nodeId = computed(() => String(route.params.id ?? ''))
20
- const daq = useDaqStream()
21
- const dcw = useDcwStream()
22
- void dcw.load()
23
- const deviceTwins = useDeviceTwins()
24
-
25
- const node = computed(() => daq.nodeById(nodeId.value) ?? null)
26
- // server 模板目录为唯一事实源:模板被删除时降级显示 key 原文,不再回退到
27
- // 打包内置常量(那会渲染过期语义)
28
- const tpl = computed(() => {
29
- const key = node.value ? daqKeyFromRef(node.value.templateRef) : ''
30
- return daq.templates.find(t => t.key === key) ?? null
31
- })
32
- const stateLabel: Record<DaqNodeState, string> = { ok: t('daqDetail.k41k5c026'), warn: t('daqDetail.k49z8v027'), alarm: t('daqDetail.k3xmid028'), offline: t('daqDetail.k44c2n029') }
33
- const effectiveState = (): DaqNodeState => {
34
- const n = node.value
35
- if (!n) return 'offline'
36
- if (!daq.controller.running || !n.enabled) return 'offline'
37
- return n.state
38
- }
39
-
40
- let unsub: (() => void) | null = null
41
- onMounted(() => {
42
- unsub = daq.ensureWsFeed()
43
- void daq.load()
44
- void deviceTwins.load()
45
- if (isFrameNode.value) void loadFrames()
46
- })
47
- onBeforeUnmount(() => unsub?.())
48
-
49
- // ---------- 帧视图(v2 多形态信号:向量轮廓 / 图像画廊;scalar 节点不渲染) ----------
50
-
51
- const signalKind = computed(() => tpl.value?.signalKind ?? 'scalar')
52
- const isFrameNode = computed(() => signalKind.value !== 'scalar')
53
- const frames = computed(() => daq.framesOf(nodeId.value))
54
-
55
- async function loadFrames(): Promise<void> {
56
- if (!nodeId.value) return
57
- await daq.fetchFrames(nodeId.value, { limit: 24 }).catch(() => {})
58
- }
59
- watch(nodeId, () => {
60
- if (isFrameNode.value) void loadFrames()
61
- })
62
-
63
- const timeOf = (at: number): string => new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
64
- const metricsBrief = (f: DaqFrameLive): string =>
65
- Object.entries(f.metrics ?? {}).slice(0, 3).map(([k, v]) => `${k}=${v}`).join(' ')
66
-
67
- /* 向量轮廓(最近一帧预览;SVG polyline,免 canvas 绘制循环) */
68
- const VEC_W = 320
69
- const VEC_H = 84
70
- const latestVector = computed(() => frames.value.find(f => f.kind === 'vector' && f.points?.length))
71
- const vecPath = computed(() => {
72
- const pts = latestVector.value?.points ?? []
73
- if (pts.length < 2) return ''
74
- const min = Math.min(...pts)
75
- const max = Math.max(...pts)
76
- const span = max - min || 1
77
- return pts
78
- .map((p, i) => `${((i / (pts.length - 1)) * VEC_W).toFixed(1)},${(VEC_H - 6 - ((p - min) / span) * (VEC_H - 12)).toFixed(1)}`)
79
- .join(' ')
80
- })
81
-
82
- /* 图像大图查看(点击缩略图;contentUrl 同源 cookie 鉴权直出) */
83
- const fullFrame = ref<DaqFrameLive | null>(null)
84
-
85
- /** WS 下发节拍展示(null=跟随全局;0=每帧;>0 独立间隔) */
86
- function publishLabel(v: number | null): string {
87
- if (v == null) return t('daqDetail.k9vnp9h053', { p0: daq.controller.defaultPublishIntervalMs })
88
- if (v === 0) return t('daqDetail.k1m1zwux030')
89
- return `${v}ms`
90
- }
91
-
92
- // ---------- 参数表单(server 单点控制;变更经 PATCH 落库,node.changed 帧回灌) ----------
93
- const form = reactive({
94
- enabled: true,
95
- driver: 'mock' as DaqDriverKind,
96
- lineId: '',
97
- followGlobal: true,
98
- intervalMs: 5000,
99
- calKind: 'none' as 'none' | 'linear',
100
- calScale: 1,
101
- calOffset: 0,
102
- publishFollow: true,
103
- publishEveryFrame: false,
104
- publishMs: 1000,
105
- unit: '',
106
- decimals: 1,
107
- min: 0,
108
- max: 100,
109
- warnLow: null as number | null,
110
- warnHigh: null as number | null,
111
- })
112
- watch(node, (n) => {
113
- if (!n) return
114
- form.enabled = n.enabled
115
- form.lineId = n.lineId ?? ''
116
- form.driver = n.driver
117
- form.followGlobal = n.intervalMs == null
118
- form.intervalMs = n.intervalMs ?? daq.controller.defaultIntervalMs
119
- // WS 下发节拍:null=跟随全局;0=每帧;>0 独立间隔
120
- form.publishFollow = n.publishIntervalMs == null
121
- form.publishEveryFrame = n.publishIntervalMs === 0
122
- form.publishMs = n.publishIntervalMs == null ? daq.controller.defaultPublishIntervalMs : n.publishIntervalMs
123
- form.unit = n.unit
124
- form.decimals = n.decimals
125
- form.min = n.min
126
- form.max = n.max
127
- form.warnLow = n.warnLow
128
- form.warnHigh = n.warnHigh
129
- }, { immediate: true })
130
-
131
- // 驱动连接参数编辑(真实协议:host/register/endpoint...;schema 驱动渲染)
132
- const driverCfg = ref<Record<string, string | number>>({})
133
- const driverFields = computed<DriverConfigField[]>(() =>
134
- DAQ_DRIVERS.find(d => d.kind === form.driver)?.configFields ?? [])
135
- watch(node, (n) => {
136
- if (n) driverCfg.value = { ...(n.driverConfig as Record<string, string | number>) }
137
- }, { immediate: true })
138
- watch(() => form.driver, () => {
139
- // 切换协议且目标无参数 → 填 schema 缺省
140
- if (driverFields.value.length && Object.keys(driverCfg.value).length === 0) {
141
- const cfg: Record<string, string | number> = {}
142
- for (const f of driverFields.value) if (f.default !== undefined) cfg[f.key] = f.default as string | number
143
- driverCfg.value = cfg
144
- }
145
- }, { immediate: true })
146
- const testing = ref(false)
147
- const testResult = ref<DaqDriverTestResult | null>(null)
148
- async function doTest(): Promise<void> {
149
- testing.value = true
150
- testResult.value = null
151
- try {
152
- // 先保存当前驱动与参数,再测(测存量节点 = 测已落库配置)
153
- await daq.patchNode(nodeId.value, { driver: form.driver, driverConfig: { ...driverCfg.value } })
154
- testResult.value = await daq.testNode(nodeId.value)
155
- }
156
- catch (err) {
157
- testResult.value = { ok: false, message: apiErrorMessage(err) }
158
- }
159
- finally {
160
- testing.value = false
161
- }
162
- }
163
-
164
- const saving = ref(false)
165
- async function saveParams(): Promise<void> {
166
- const n = node.value
167
- if (!n || saving.value) return
168
- saving.value = true
169
- try {
170
- await daq.patchNode(n.id, {
171
- enabled: form.enabled,
172
- driver: form.driver,
173
- lineId: form.lineId,
174
- driverConfig: { ...driverCfg.value },
175
- intervalMs: form.followGlobal ? null : Math.max(1000, Math.min(60_000, Math.round(form.intervalMs))),
176
- publishIntervalMs: form.publishFollow
177
- ? null
178
- : (form.publishEveryFrame ? 0 : Math.max(0, Math.min(60_000, Math.round(form.publishMs)))),
179
- unit: form.unit,
180
- decimals: Math.max(0, Math.min(6, Math.round(form.decimals))),
181
- min: form.min,
182
- max: form.max,
183
- warnLow: form.warnLow,
184
- warnHigh: form.warnHigh,
185
- })
186
- }
187
- finally {
188
- saving.value = false
189
- }
190
- }
191
-
192
- /** 驱动是否为预留协议(meta status=planned) */
193
- function driverPlanned(kind: string): boolean {
194
- return daq.meta.drivers.find(d => d.kind === kind)?.status === 'planned'
195
- }
196
-
197
- // ---------- 设备绑定 ----------
198
- const bindDeviceId = ref('')
199
- const boundDeviceName = computed(() => {
200
- const id = node.value?.deviceBindingId
201
- if (!id) return ''
202
- return deviceTwins.twins.find(t => t.id === id)?.name ?? `${id.slice(0, 8)}…`
203
- })
204
- function onBindToggle(): void {
205
- void daq.bindNode(nodeId.value, node.value?.deviceBindingId ? null : (bindDeviceId.value || null))
206
- }
207
- const availableDevices = computed(() =>
208
- deviceTwins.twins.filter(t => t.kind !== 'daq' && t.id !== node.value?.id),
209
- )
210
-
211
- // ---------- 历史(时序库) ----------
212
- type ChartRow = { at: number, value?: number, avg?: number, min?: number, max?: number }
213
- const historyPoints = ref<ChartRow[]>([])
214
- const bucketMs = ref<number>(15000)
215
- const histLoading = ref(false)
216
- const BUCKETS = [
217
- { label: '1s 桶', ms: 1000 },
218
- { label: '5s 桶', ms: 5000 },
219
- { label: '15s 桶(默认)', ms: 15000 },
220
- { label: '30s 桶', ms: 30000 },
221
- { label: '1min 桶', ms: 60000 },
222
- ]
223
- async function loadHistory(): Promise<void> {
224
- if (!nodeId.value) return
225
- histLoading.value = true
226
- try {
227
- const pts = await daq.samplesOf(nodeId.value, {
228
- toMs: Date.now(),
229
- bucketMs: bucketMs.value || undefined,
230
- limit: 400,
231
- })
232
- // 接口 DESC 返回 → 图表时间正序
233
- historyPoints.value = [...pts].reverse() as ChartRow[]
234
- }
235
- finally {
236
- histLoading.value = false
237
- drawChart()
238
- }
239
- }
240
-
241
- const chartCanvas = ref<HTMLCanvasElement | null>(null)
242
- const trendColor = '#35e0a0'
243
- const gridColor = 'rgba(120,135,160,0.18)'
244
- const inkColor = 'rgba(140,155,175,0.8)'
245
- function drawChart(): void {
246
- const cv = chartCanvas.value
247
- if (!cv) return
248
- const dpr = Math.min(window.devicePixelRatio || 1, 2)
249
- const w = cv.clientWidth
250
- const h = cv.clientHeight
251
- if (cv.width !== w * dpr || cv.height !== h * dpr) {
252
- cv.width = w * dpr
253
- cv.height = h * dpr
254
- }
255
- const ctx = cv.getContext('2d')
256
- if (!ctx) return
257
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
258
- ctx.clearRect(0, 0, w, h)
259
- for (let i = 1; i < 4; i++) {
260
- ctx.strokeStyle = gridColor
261
- ctx.beginPath()
262
- ctx.moveTo(0, (h / 4) * i)
263
- ctx.lineTo(w, (h / 4) * i)
264
- ctx.stroke()
265
- }
266
- const rows = historyPoints.value
267
- if (rows.length < 2) return
268
- let lo = Number.POSITIVE_INFINITY
269
- let hi = Number.NEGATIVE_INFINITY
270
- for (const r of rows) {
271
- const v = r.value ?? r.avg ?? r.min ?? 0
272
- lo = Math.min(lo, v)
273
- hi = Math.max(hi, v)
274
- }
275
- const span = Math.max(hi - lo, 1e-9)
276
- ctx.strokeStyle = inkColor
277
- ctx.font = '10px monospace'
278
- ctx.fillStyle = inkColor
279
- ctx.fillText(hi.toFixed(2), 4, 11)
280
- ctx.fillText(lo.toFixed(2), 4, h - 4)
281
- ctx.strokeStyle = trendColor
282
- ctx.lineWidth = 1.6
283
- ctx.beginPath()
284
- rows.forEach((r, i) => {
285
- const x = (i / (rows.length - 1)) * w
286
- const y = h - (((r.value ?? r.avg ?? 0) - lo) / span) * (h - 16) - 8
287
- if (i === 0) ctx.moveTo(x, y)
288
- else ctx.lineTo(x, y)
289
- })
290
- ctx.stroke()
291
- }
292
-
293
- /** live 趋势(WS hist 环形缓冲;独立小画布,与历史图互补) */
294
- const liveCanvas = ref<HTMLCanvasElement | null>(null)
295
- let uiTimer: ReturnType<typeof setInterval> | null = null
296
- function drawLive(): void {
297
- const cv = liveCanvas.value
298
- const n = node.value
299
- if (!cv || !n || n.hist.length < 2) return
300
- const dpr = Math.min(window.devicePixelRatio || 1, 2)
301
- const w = cv.clientWidth
302
- const h = cv.clientHeight
303
- if (cv.width !== w * dpr || cv.height !== h * dpr) {
304
- cv.width = w * dpr
305
- cv.height = h * dpr
306
- }
307
- const ctx = cv.getContext('2d')
308
- if (!ctx) return
309
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
310
- ctx.clearRect(0, 0, w, h)
311
- const hi = Math.max(...n.hist)
312
- const lo = Math.min(...n.hist)
313
- const span = Math.max(hi - lo, 1e-9)
314
- ctx.strokeStyle = 'rgba(65,200,244,0.9)'
315
- ctx.lineWidth = 1.5
316
- ctx.beginPath()
317
- n.hist.forEach((v, i) => {
318
- const x = (i / (n.hist.length - 1)) * w
319
- const y = h - ((v - lo) / span) * (h - 6) - 3
320
- if (i === 0) ctx.moveTo(x, y)
321
- else ctx.lineTo(x, y)
322
- })
323
- ctx.stroke()
324
- }
325
- onMounted(() => {
326
- void loadHistory()
327
- // live 趋势重绘循环(仅浏览器;SSR 安全)
328
- uiTimer = setInterval(() => {
329
- drawLive()
330
- }, 800)
331
- })
332
- onBeforeUnmount(() => {
333
- if (uiTimer) clearInterval(uiTimer)
334
- })
335
-
336
- watch(bucketMs, () => void loadHistory())
337
- </script>
338
-
339
- <template>
340
- <div class="page">
341
- <p class="aw-kicker">
342
- <NuxtLink
343
- to="/daq"
344
- class="back"
345
- >{{ $t('daqDetail.k1emg364001') }}</NuxtLink> / NODE {{ nodeId.slice(0, 8).toUpperCase() }}
346
- </p>
347
- <div class="aw-page-head">
348
- <h1>{{ node?.name ?? nodeId }}</h1>
349
- <span
350
- class="st-pill"
351
- :class="[effectiveState()]"
352
- >{{ stateLabel[effectiveState()] }}</span>
353
- </div>
354
-
355
- <p
356
- v-if="!node && daq.error"
357
- class="err"
358
- >
359
- {{ daq.error }}(<NuxtLink to="/workshop">{{ $t('daqDetail.k1bhhheq002') }}</NuxtLink>)
360
- </p>
361
-
362
- <div
363
- v-if="node"
364
- class="grid"
365
- >
366
- <!-- 左列:实时状态卡 -->
367
- <section class="col-live">
368
- <div class="aw-tile value-card">
369
- <p class="aw-kicker">
370
- {{ $t('daqDetail.k1fx2vik032') }} {{ tpl?.ch ?? '-' }}
371
- </p>
372
- <div class="big-val mono">
373
- {{ node.value != null ? node.value.toFixed(node.decimals) : '--' }}<small>{{ node.unit }}</small>
374
- </div>
375
- <canvas
376
- ref="liveCanvas"
377
- class="live-canvas"
378
- />
379
- <dl class="facts mono">
380
- <div>
381
- <dt>{{ $t('daqDetail.k4a0la003') }}</dt>
382
- <dd>{{ node.driver }}{{ driverPlanned(node.driver) ? $t('daqDetail.kz8zr9v035') : '' }}</dd>
383
- </div>
384
- <div>
385
- <dt>{{ $t('daqDetail.k3xg3w004') }}</dt>
386
- <dd>{{ node.intervalMs ?? $t('daqDetail.k9vnp9h053', { p0: daq.controller.defaultIntervalMs }) }}</dd>
387
- </div>
388
- <div>
389
- <dt>{{ $t('daqDetail.k3w6td005') }}</dt>
390
- <dd>{{ publishLabel(node.publishIntervalMs) }}</dd>
391
- </div>
392
- <div>
393
- <dt>{{ $t('daqDetail.k3x5tpx006') }}</dt>
394
- <dd>{{ node.warnLow ?? '-∞' }} ~ {{ node.warnHigh ?? '+∞' }}</dd>
395
- </div>
396
- <div>
397
- <dt>{{ $t('daqDetail.k1hjj0jf007') }}</dt>
398
- <dd>{{ node.min }} ~ {{ node.max }}</dd>
399
- </div>
400
- <div>
401
- <dt>{{ $t('daqDetail.k1i8rtqt008') }}</dt>
402
- <dd>{{ boundDeviceName || $t('daqDetail.k3own4q036') }}</dd>
403
- </div>
404
- </dl>
405
- </div>
406
- </section>
407
-
408
- <!-- 中列:参数控制 -->
409
- <section class="col-form aw-tile pad">
410
- <h3 class="sec">
411
- {{ $t('daqDetail.kd9d69j009') }}
412
- </h3>
413
- <form
414
- class="form-grid"
415
- @submit.prevent="saveParams"
416
- >
417
- <label class="field">
418
- <span>{{ $t('daqDetail.k1iw7tbf010') }}</span>
419
- <button
420
- type="button"
421
- class="toggle"
422
- :class="{ on: form.enabled }"
423
- @click="form.enabled = !form.enabled"
424
- >
425
- {{ form.enabled ? $t('daqDetail.k3w3j8f037') : $t('daqDetail.k3n5aaj045') }}
426
- </button>
427
- </label>
428
- <label class="field">
429
- <span>{{ $t('daqDetail.k12k23od011') }}</span>
430
- <select
431
- v-model="form.lineId"
432
- class="input"
433
- >
434
- <option value="">
435
- {{ $t('daqDetail.k3ootr6012') }}
436
- </option>
437
- <option
438
- v-for="l in dcw.lines"
439
- :key="l.id"
440
- :value="l.id"
441
- >
442
- {{ l.name }}
443
- </option>
444
- </select>
445
- </label>
446
- <label class="field">
447
- <span>{{ $t('daqDetail.k1l6smxo013') }}</span>
448
- <select
449
- v-model="form.driver"
450
- class="input"
451
- >
452
- <option
453
- v-for="d in DAQ_DRIVERS"
454
- :key="d.kind"
455
- :value="d.kind"
456
- :disabled="d.status === 'planned'"
457
- >
458
- {{ d.label }}{{ d.status === 'planned' ? $t('daqDetail.kz8zr9v035') : '' }}
459
- </option>
460
- </select>
461
- </label>
462
- <label class="field row">
463
- <button
464
- type="button"
465
- class="toggle slim"
466
- :class="{ on: form.followGlobal }"
467
- @click="form.followGlobal = !form.followGlobal"
468
- >
469
- {{ form.followGlobal ? $t('daqDetail.k1k2ueyq038') : $t('daqDetail.k1cgi8fy046') }}
470
- </button>
471
- <input
472
- v-model.number="form.intervalMs"
473
- type="number"
474
- min="1000"
475
- max="60000"
476
- step="500"
477
- class="input"
478
- :disabled="form.followGlobal"
479
- ><small>ms</small>
480
- </label>
481
- <label class="field row">
482
- <button
483
- type="button"
484
- class="toggle slim"
485
- :class="{ on: form.calKind === 'linear' }"
486
- title="采集标定 decoder:物理值 = scale × PLC采集值 + offset"
487
- @click="form.calKind = form.calKind === 'linear' ? 'none' : 'linear'"
488
- >
489
- {{ form.calKind === 'linear' ? $t('daqDetail.k1eru4r1039') : $t('daqDetail.k3oktae047') }}
490
- </button>
491
- <input
492
- v-model.number="form.calScale"
493
- type="number"
494
- step="0.1"
495
- class="input"
496
- :disabled="form.calKind !== 'linear'"
497
- title="decode 斜率(≠0)"
498
- >
499
- <input
500
- v-model.number="form.calOffset"
501
- type="number"
502
- step="0.1"
503
- class="input"
504
- :disabled="form.calKind !== 'linear'"
505
- title="decode 截距"
506
- ><small>decoder</small>
507
- </label>
508
- <label class="field row">
509
- <button
510
- type="button"
511
- class="toggle slim"
512
- :class="{ on: form.publishFollow }"
513
- @click="form.publishFollow = !form.publishFollow"
514
- >
515
- {{ form.publishFollow ? $t('daqDetail.k1k2t5o7040') : $t('daqDetail.k1cggz5f048') }}
516
- </button>
517
- <span
518
- class="toggle slim"
519
- :class="{ on: form.publishEveryFrame }"
520
- style="cursor: pointer;"
521
- @click="form.publishEveryFrame = !form.publishEveryFrame"
522
- >{{ form.publishEveryFrame ? $t('daqDetail.k41mvv041') : $t('daqDetail.k3n42dj049') }}</span>
523
- <input
524
- v-model.number="form.publishMs"
525
- type="number"
526
- min="0"
527
- max="60000"
528
- step="100"
529
- class="input"
530
- :disabled="form.publishFollow || form.publishEveryFrame"
531
- ><small>ms</small>
532
- </label>
533
- <label class="field">
534
- <span>{{ $t('daqDetail.k3x4ef014') }}</span>
535
- <input
536
- v-model="form.unit"
537
- class="input"
538
- >
539
- </label>
540
- <label class="field">
541
- <span>{{ $t('daqDetail.k3mxmcx015') }}</span>
542
- <input
543
- v-model.number="form.decimals"
544
- type="number"
545
- min="0"
546
- max="6"
547
- class="input"
548
- >
549
- </label>
550
- <div class="field-row">
551
- <label class="field">
552
- <span>{{ $t('daqDetail.k1l9jv5m016') }}</span>
553
- <input
554
- v-model.number="form.min"
555
- type="number"
556
- step="any"
557
- class="input"
558
- >
559
- </label>
560
- <label class="field">
561
- <span>{{ $t('daqDetail.k1md63hm017') }}</span>
562
- <input
563
- v-model.number="form.warnLow"
564
- type="number"
565
- step="any"
566
- class="input"
567
- >
568
- </label>
569
- <label class="field">
570
- <span>{{ $t('daqDetail.k1md63gp018') }}</span>
571
- <input
572
- v-model.number="form.warnHigh"
573
- type="number"
574
- step="any"
575
- class="input"
576
- >
577
- </label>
578
- <label class="field">
579
- <span>{{ $t('daqDetail.k1l9jv4p019') }}</span>
580
- <input
581
- v-model.number="form.max"
582
- type="number"
583
- step="any"
584
- class="input"
585
- >
586
- </label>
587
- </div>
588
- <button
589
- class="aw-pill"
590
- type="submit"
591
- :disabled="saving"
592
- >
593
- {{ saving ? $t('daqDetail.k1as0g50042') : $t('daqDetail.ksn9cgo050') }}
594
- </button>
595
- </form>
596
-
597
- <!-- 驱动连接参数(mock 空;真实协议 schema 动态表单 + 测试连接) -->
598
- <template v-if="driverFields.length">
599
- <h3 class="sec mt">
600
- {{ $t('daqDetail.k1p9gioa033') }} {{ DAQ_DRIVERS.find(d => d.kind === form.driver)?.label }}
601
- </h3>
602
- <div class="driver-grid">
603
- <label
604
- v-for="f in driverFields"
605
- :key="f.key"
606
- class="field"
607
- >
608
- <span>{{ f.label }}<em v-if="f.required">*</em></span>
609
- <select
610
- v-if="f.type === 'select'"
611
- v-model="driverCfg[f.key]"
612
- class="input"
613
- >
614
- <option
615
- v-for="o in f.options"
616
- :key="o.value"
617
- :value="o.value"
618
- >
619
- {{ o.label }}
620
- </option>
621
- </select>
622
- <input
623
- v-else
624
- v-model="driverCfg[f.key]"
625
- :type="f.type === 'number' ? 'number' : 'text'"
626
- :placeholder="f.placeholder"
627
- class="input"
628
- >
629
- </label>
630
- </div>
631
- <div class="test-row">
632
- <button
633
- class="pill-btn"
634
- :disabled="testing"
635
- @click="doTest"
636
- >
637
- {{ testing ? $t('daqDetail.k1fsh720043') : $t('daqDetail.k1fstglk051') }}
638
- </button>
639
- <span
640
- v-if="testResult"
641
- class="test-out mono"
642
- :class="testResult.ok ? 'ok' : 'bad'"
643
- >{{ testResult.ok ? '✓' : '✗' }} {{ testResult.message }}</span>
644
- </div>
645
- </template>
646
-
647
- <h3 class="sec mt">
648
- {{ $t('daqDetail.k1ie6qju020') }}
649
- </h3>
650
- <div class="bind-row">
651
- <select
652
- v-model="bindDeviceId"
653
- class="input grow"
654
- >
655
- <option value="">
656
- {{ $t('daqDetail.kjobzu3021') }}
657
- </option>
658
- <option
659
- v-for="dv in availableDevices"
660
- :key="dv.id"
661
- :value="dv.id"
662
- >
663
- {{ dv.name }}
664
- </option>
665
- </select>
666
- <button
667
- class="pill-btn"
668
- @click="onBindToggle"
669
- >
670
- {{ boundDeviceName ? $t('daqDetail.k479eh044') : $t('daqDetail.k452a8052') }}
671
- </button>
672
- </div>
673
- </section>
674
-
675
- <!-- 右列:时序库历史 -->
676
- <section class="col-hist aw-tile pad">
677
- <div class="hist-hd">
678
- <h3 class="sec">
679
- {{ $t('daqDetail.k1i535i5034') }}{{ daq.meta.tsdb }})
680
- </h3>
681
- <div class="hist-ctl mono">
682
- <select
683
- v-model.number="bucketMs"
684
- class="input"
685
- >
686
- <option
687
- v-for="b in BUCKETS"
688
- :key="b.ms"
689
- :value="b.ms"
690
- >
691
- {{ b.label }}
692
- </option>
693
- </select>
694
- <button
695
- class="pill-btn"
696
- @click="loadHistory"
697
- >
698
- {{ $t('daqDetail.k3x1jg022') }}
699
- </button>
700
- </div>
701
- </div>
702
- <canvas
703
- ref="chartCanvas"
704
- class="hist-canvas"
705
- />
706
- <table class="raw-table mono">
707
- <thead>
708
- <tr><th>{{ $t('daqDetail.k40vsf023') }}</th><th>{{ $t('daqDetail.k48v5024') }}</th><th>{{ $t('daqDetail.k4bza025') }}</th></tr>
709
- </thead>
710
- <tbody>
711
- <tr
712
- v-for="(p, i) in historyPoints.filter(r => r.value != null).slice(-12).reverse()"
713
- :key="i"
714
- >
715
- <td>{{ new Date(p.at).toLocaleTimeString('zh-CN', { hour12: false }) }}</td>
716
- <td>{{ p.value!.toFixed(node?.decimals ?? 2) }}</td>
717
- <td>{{ p.state }}</td>
718
- </tr>
719
- </tbody>
720
- </table>
721
- </section>
722
- </div>
723
-
724
- <!-- 帧视图(v2 多形态信号:向量轮廓 / 图像画廊;scalar 节点不渲染) -->
725
- <section
726
- v-if="isFrameNode"
727
- class="aw-tile pad frames-tile"
728
- >
729
- <div class="hist-hd">
730
- <h3 class="sec">
731
- {{ signalKind === 'image' ? $t('daqDetail.kfrm0001') : $t('daqDetail.kfrm0002') }}
732
- <span class="mono frames-n">{{ frames.length }}</span>
733
- </h3>
734
- <button
735
- class="pill-btn"
736
- @click="loadFrames"
737
- >
738
- {{ $t('daqDetail.k3x1jg022') }}
739
- </button>
740
- </div>
741
-
742
- <!-- 向量:最近帧轮廓 + 帧表 -->
743
- <template v-if="signalKind === 'vector'">
744
- <svg
745
- class="vec-svg"
746
- :viewBox="`0 0 ${VEC_W} ${VEC_H}`"
747
- preserveAspectRatio="none"
748
- >
749
- <polyline
750
- :points="vecPath"
751
- fill="none"
752
- stroke="var(--accent)"
753
- stroke-width="1.5"
754
- />
755
- </svg>
756
- <table class="raw-table mono">
757
- <thead>
758
- <tr><th>{{ $t('daqDetail.k40vsf023') }}</th><th>{{ $t('daqDetail.kfrm0004') }}</th><th>{{ $t('daqDetail.kfrm0005') }}</th></tr>
759
- </thead>
760
- <tbody>
761
- <tr
762
- v-for="f in frames.filter(x => x.kind === 'vector').slice(0, 12)"
763
- :key="f.at"
764
- >
765
- <td>{{ timeOf(f.at) }}</td>
766
- <td>{{ f.points?.length ?? 0 }}</td>
767
- <td>{{ metricsBrief(f) }}</td>
768
- </tr>
769
- </tbody>
770
- </table>
771
- </template>
772
-
773
- <!-- 图像:缩略图画gallery(懒加载;点击看原图) -->
774
- <template v-else>
775
- <div
776
- v-if="frames.length"
777
- class="gal"
778
- >
779
- <figure
780
- v-for="f in frames"
781
- :key="f.at"
782
- class="gal-item"
783
- :title="$t('daqDetail.kfrm0006')"
784
- @click="fullFrame = f"
785
- >
786
- <img
787
- :src="f.thumbUrl"
788
- loading="lazy"
789
- alt="daq frame"
790
- >
791
- <figcaption class="mono">
792
- {{ timeOf(f.at) }} · {{ metricsBrief(f) }}
793
- </figcaption>
794
- </figure>
795
- </div>
796
- <p
797
- v-else
798
- class="frames-empty"
799
- >
800
- {{ $t('daqDetail.kfrm0003') }}
801
- </p>
802
- <div
803
- v-if="fullFrame"
804
- class="gal-full"
805
- @click="fullFrame = null"
806
- >
807
- <img
808
- :src="fullFrame.contentUrl ?? fullFrame.thumbUrl"
809
- alt="daq frame full"
810
- >
811
- </div>
812
- </template>
813
- </section>
814
- </div>
815
- </template>
816
-
817
- <style scoped>
818
- .page { padding: 4px; }
819
- .back { color: var(--ink-faint); text-decoration: none; }
820
- .back:hover { color: var(--accent); }
821
- .aw-page-head h1 { margin: 2px 0 4px; font-size: 26px; font-weight: 400; letter-spacing: -0.01em; }
822
- .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
823
- .sec { margin: 0 0 10px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }
824
- .mt { margin-top: 22px; }
825
- .err { margin-top: 14px; font-size: 13px; color: var(--tone-danger-dot); }
826
-
827
- .grid {
828
- display: grid;
829
- gap: 14px;
830
- align-items: start;
831
- grid-template-columns: minmax(260px, 340px) minmax(320px, 1fr) minmax(280px, 380px);
832
- }
833
- @media (max-width: 1100px) {
834
- .grid { grid-template-columns: 1fr; }
835
- }
836
- .pad { padding: 16px 18px; }
837
-
838
- /* 实时值卡 */
839
- .value-card { padding: 16px 18px; }
840
- .big-val { margin: 8px 0 10px; font-size: 40px; line-height: 1.1; color: var(--accent); }
841
- .big-val small { margin-left: 8px; font-size: 15px; color: var(--ink-faint); }
842
- .live-canvas { width: 100%; height: 64px; border: 1px solid var(--line); border-radius: var(--radius-chip); background: var(--paper-deep); }
843
- .facts { display: flex; flex-direction: column; gap: 4px; margin: 14px 0 0; }
844
- .facts div { display: flex; justify-content: space-between; font-size: 11.5px; }
845
- .facts dt { color: var(--ink-faint); }
846
- .facts dd { margin: 0; color: var(--ink); }
847
-
848
- /* 参数表单 */
849
- .form-grid { display: flex; flex-direction: column; gap: 10px; }
850
- .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-faint); flex: 1; }
851
- .field.row { flex-direction: row; align-items: center; gap: 8px; }
852
- .field.row small { color: var(--ink-faint); }
853
- .field-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
854
- .input {
855
- width: 100%;
856
- padding: 6px 9px;
857
- font-size: 12.5px;
858
- color: var(--ink);
859
- background: var(--paper-raised);
860
- border: 1px solid var(--line-strong);
861
- border-radius: var(--radius-chip);
862
- }
863
- .input:disabled { opacity: 0.45; }
864
- .grow { flex: 1 1 auto; }
865
- .toggle {
866
- align-self: flex-start;
867
- padding: 6px 12px;
868
- font-size: 12px;
869
- cursor: pointer;
870
- color: var(--tone-neutral-dot);
871
- background: var(--tone-neutral-bg);
872
- border: 0;
873
- border-radius: var(--radius-chip);
874
- }
875
- .toggle.on { color: var(--tone-success-dot); background: var(--tone-success-bg); }
876
- .toggle.slim { padding: 6px 10px; font-size: 11.5px; }
877
- .pill-btn {
878
- padding: 7px 14px;
879
- font-size: 12.5px;
880
- cursor: pointer;
881
- color: var(--paper-raised);
882
- background: var(--accent);
883
- border: 1px solid var(--accent);
884
- border-radius: var(--radius-pill);
885
- }
886
- .bind-row { display: flex; gap: 8px; }
887
- .driver-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
888
- .field em { margin-left: 2px; font-style: normal; color: var(--tone-danger-dot); }
889
- .test-row { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
890
- .pill-btn {
891
- padding: 7px 14px;
892
- font-size: 12.5px;
893
- cursor: pointer;
894
- color: var(--paper-raised);
895
- background: var(--accent);
896
- border: 1px solid var(--accent);
897
- border-radius: var(--radius-pill);
898
- }
899
- .pill-btn:disabled { opacity: 0.5; }
900
- .test-out { font-size: 11px; }
901
- .test-out.ok { color: var(--tone-success-dot); }
902
- .test-out.bad { color: var(--tone-danger-dot); }
903
-
904
- /* 历史 */
905
- .col-hist { min-width: 0; }
906
- .hist-hd { display: flex; gap: 10px; align-items: center; justify-content: space-between; margin-bottom: 10px; }
907
- .hist-ctl { display: flex; gap: 6px; }
908
- .hist-canvas { width: 100%; height: 130px; border: 1px solid var(--line); border-radius: var(--radius-chip); background: var(--paper-deep); }
909
- .raw-table { width: 100%; margin-top: 10px; font-size: 11px; border-collapse: collapse; }
910
- .raw-table th, .raw-table td { padding: 4px 6px; text-align: left; border-bottom: 1px solid var(--divider-hair); }
911
- .raw-table th { font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink-fainter); }
912
-
913
- .st-pill {
914
- display: inline-block;
915
- padding: 3px 11px;
916
- font-family: var(--font-mono);
917
- font-size: 11px;
918
- letter-spacing: 0.04em;
919
- border-radius: var(--radius-pill);
920
- }
921
- .st-pill.ok { color: var(--tone-success-dot); background: var(--tone-success-bg); }
922
- .st-pill.warn { color: var(--tone-warning-dot); background: var(--tone-warning-bg); }
923
- .st-pill.alarm { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
924
- .st-pill.offline { color: var(--tone-neutral-dot); background: var(--tone-neutral-bg); }
925
-
926
- /* 帧视图(v2 多形态) */
927
- .frames-tile { margin-top: 14px; }
928
- .frames-n { margin-left: 8px; color: var(--ink-faint); }
929
- .vec-svg { width: 100%; height: 96px; display: block; background: var(--tone-neutral-bg); border-radius: 6px; margin-bottom: 10px; }
930
- .gal { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
931
- .gal-item { margin: 0; cursor: zoom-in; }
932
- .gal-item img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 6px; display: block; background: var(--tone-neutral-bg); }
933
- .gal-item figcaption { margin-top: 4px; font-size: 11px; color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
934
- .frames-empty { margin: 8px 0; font-size: 13px; color: var(--ink-faint); }
935
- .gal-full { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 72%); cursor: zoom-out; }
936
- .gal-full img { max-width: 92vw; max-height: 92vh; border-radius: 8px; }
937
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * 数采节点控制台(/daq/[id])—— 单节点专业控制面。
4
+ * 实时值 + live 趋势(WS 流);参数控制(server DaqNode 单点参数:启停/周期/
5
+ * 驱动/量程/预警带);设备绑定;时序库历史查询(降采样桶选择)。
6
+ */
7
+ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
8
+ import { useRoute } from 'vue-router'
9
+ import { useDcwStream } from '@/app/composables/workshop/useDcwStream'
10
+ import { useDaqStream, type DaqFrameLive } from '@/app/composables/workshop/useDaqStream'
11
+ import { useDeviceTwins } from '@/app/composables/workshop/useDeviceTwins'
12
+ import { daqKeyFromRef, DAQ_DRIVERS, type DaqDriverKind, type DaqNodeState, type DriverConfigField, type DriverTestResult as DaqDriverTestResult } from '#shared/daq-protocol'
13
+
14
+ const { t } = useI18n()
15
+
16
+ definePageMeta({ layout: 'default' })
17
+
18
+ const route = useRoute()
19
+ const nodeId = computed(() => String(route.params.id ?? ''))
20
+ const daq = useDaqStream()
21
+ const dcw = useDcwStream()
22
+ void dcw.load()
23
+ const deviceTwins = useDeviceTwins()
24
+
25
+ const node = computed(() => daq.nodeById(nodeId.value) ?? null)
26
+ // server 模板目录为唯一事实源:模板被删除时降级显示 key 原文,不再回退到
27
+ // 打包内置常量(那会渲染过期语义)
28
+ const tpl = computed(() => {
29
+ const key = node.value ? daqKeyFromRef(node.value.templateRef) : ''
30
+ return daq.templates.find(t => t.key === key) ?? null
31
+ })
32
+ const stateLabel: Record<DaqNodeState, string> = { ok: t('daqDetail.k41k5c026'), warn: t('daqDetail.k49z8v027'), alarm: t('daqDetail.k3xmid028'), offline: t('daqDetail.k44c2n029') }
33
+ const effectiveState = (): DaqNodeState => {
34
+ const n = node.value
35
+ if (!n) return 'offline'
36
+ if (!daq.controller.running || !n.enabled) return 'offline'
37
+ return n.state
38
+ }
39
+
40
+ let unsub: (() => void) | null = null
41
+ onMounted(() => {
42
+ unsub = daq.ensureWsFeed()
43
+ void daq.load()
44
+ void deviceTwins.load()
45
+ if (isFrameNode.value) void loadFrames()
46
+ })
47
+ onBeforeUnmount(() => unsub?.())
48
+
49
+ // ---------- 帧视图(v2 多形态信号:向量轮廓 / 图像画廊;scalar 节点不渲染) ----------
50
+
51
+ const signalKind = computed(() => tpl.value?.signalKind ?? 'scalar')
52
+ const isFrameNode = computed(() => signalKind.value !== 'scalar')
53
+ const frames = computed(() => daq.framesOf(nodeId.value))
54
+
55
+ async function loadFrames(): Promise<void> {
56
+ if (!nodeId.value) return
57
+ await daq.fetchFrames(nodeId.value, { limit: 24 }).catch(() => {})
58
+ }
59
+ watch(nodeId, () => {
60
+ if (isFrameNode.value) void loadFrames()
61
+ })
62
+
63
+ const timeOf = (at: number): string => new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
64
+ const metricsBrief = (f: DaqFrameLive): string =>
65
+ Object.entries(f.metrics ?? {}).slice(0, 3).map(([k, v]) => `${k}=${v}`).join(' ')
66
+
67
+ /* 向量轮廓(最近一帧预览;SVG polyline,免 canvas 绘制循环) */
68
+ const VEC_W = 320
69
+ const VEC_H = 84
70
+ const latestVector = computed(() => frames.value.find(f => f.kind === 'vector' && f.points?.length))
71
+ const vecPath = computed(() => {
72
+ const pts = latestVector.value?.points ?? []
73
+ if (pts.length < 2) return ''
74
+ const min = Math.min(...pts)
75
+ const max = Math.max(...pts)
76
+ const span = max - min || 1
77
+ return pts
78
+ .map((p, i) => `${((i / (pts.length - 1)) * VEC_W).toFixed(1)},${(VEC_H - 6 - ((p - min) / span) * (VEC_H - 12)).toFixed(1)}`)
79
+ .join(' ')
80
+ })
81
+
82
+ /* 图像大图查看(点击缩略图;contentUrl 同源 cookie 鉴权直出) */
83
+ const fullFrame = ref<DaqFrameLive | null>(null)
84
+
85
+ /** WS 下发节拍展示(null=跟随全局;0=每帧;>0 独立间隔) */
86
+ function publishLabel(v: number | null): string {
87
+ if (v == null) return t('daqDetail.k9vnp9h053', { p0: daq.controller.defaultPublishIntervalMs })
88
+ if (v === 0) return t('daqDetail.k1m1zwux030')
89
+ return `${v}ms`
90
+ }
91
+
92
+ // ---------- 参数表单(server 单点控制;变更经 PATCH 落库,node.changed 帧回灌) ----------
93
+ const form = reactive({
94
+ enabled: true,
95
+ driver: 'mock' as DaqDriverKind,
96
+ lineId: '',
97
+ followGlobal: true,
98
+ intervalMs: 5000,
99
+ calKind: 'none' as 'none' | 'linear',
100
+ calScale: 1,
101
+ calOffset: 0,
102
+ publishFollow: true,
103
+ publishEveryFrame: false,
104
+ publishMs: 1000,
105
+ unit: '',
106
+ decimals: 1,
107
+ min: 0,
108
+ max: 100,
109
+ warnLow: null as number | null,
110
+ warnHigh: null as number | null,
111
+ })
112
+ watch(node, (n) => {
113
+ if (!n) return
114
+ form.enabled = n.enabled
115
+ form.lineId = n.lineId ?? ''
116
+ form.driver = n.driver
117
+ form.followGlobal = n.intervalMs == null
118
+ form.intervalMs = n.intervalMs ?? daq.controller.defaultIntervalMs
119
+ // WS 下发节拍:null=跟随全局;0=每帧;>0 独立间隔
120
+ form.publishFollow = n.publishIntervalMs == null
121
+ form.publishEveryFrame = n.publishIntervalMs === 0
122
+ form.publishMs = n.publishIntervalMs == null ? daq.controller.defaultPublishIntervalMs : n.publishIntervalMs
123
+ form.unit = n.unit
124
+ form.decimals = n.decimals
125
+ form.min = n.min
126
+ form.max = n.max
127
+ form.warnLow = n.warnLow
128
+ form.warnHigh = n.warnHigh
129
+ }, { immediate: true })
130
+
131
+ // 驱动连接参数编辑(真实协议:host/register/endpoint...;schema 驱动渲染)
132
+ const driverCfg = ref<Record<string, string | number>>({})
133
+ const driverFields = computed<DriverConfigField[]>(() =>
134
+ DAQ_DRIVERS.find(d => d.kind === form.driver)?.configFields ?? [])
135
+ watch(node, (n) => {
136
+ if (n) driverCfg.value = { ...(n.driverConfig as Record<string, string | number>) }
137
+ }, { immediate: true })
138
+ watch(() => form.driver, () => {
139
+ // 切换协议且目标无参数 → 填 schema 缺省
140
+ if (driverFields.value.length && Object.keys(driverCfg.value).length === 0) {
141
+ const cfg: Record<string, string | number> = {}
142
+ for (const f of driverFields.value) if (f.default !== undefined) cfg[f.key] = f.default as string | number
143
+ driverCfg.value = cfg
144
+ }
145
+ }, { immediate: true })
146
+ const testing = ref(false)
147
+ const testResult = ref<DaqDriverTestResult | null>(null)
148
+ async function doTest(): Promise<void> {
149
+ testing.value = true
150
+ testResult.value = null
151
+ try {
152
+ // 先保存当前驱动与参数,再测(测存量节点 = 测已落库配置)
153
+ await daq.patchNode(nodeId.value, { driver: form.driver, driverConfig: { ...driverCfg.value } })
154
+ testResult.value = await daq.testNode(nodeId.value)
155
+ }
156
+ catch (err) {
157
+ testResult.value = { ok: false, message: apiErrorMessage(err) }
158
+ }
159
+ finally {
160
+ testing.value = false
161
+ }
162
+ }
163
+
164
+ const saving = ref(false)
165
+ async function saveParams(): Promise<void> {
166
+ const n = node.value
167
+ if (!n || saving.value) return
168
+ saving.value = true
169
+ try {
170
+ await daq.patchNode(n.id, {
171
+ enabled: form.enabled,
172
+ driver: form.driver,
173
+ lineId: form.lineId,
174
+ driverConfig: { ...driverCfg.value },
175
+ intervalMs: form.followGlobal ? null : Math.max(1000, Math.min(60_000, Math.round(form.intervalMs))),
176
+ publishIntervalMs: form.publishFollow
177
+ ? null
178
+ : (form.publishEveryFrame ? 0 : Math.max(0, Math.min(60_000, Math.round(form.publishMs)))),
179
+ unit: form.unit,
180
+ decimals: Math.max(0, Math.min(6, Math.round(form.decimals))),
181
+ min: form.min,
182
+ max: form.max,
183
+ warnLow: form.warnLow,
184
+ warnHigh: form.warnHigh,
185
+ })
186
+ }
187
+ finally {
188
+ saving.value = false
189
+ }
190
+ }
191
+
192
+ /** 驱动是否为预留协议(meta status=planned) */
193
+ function driverPlanned(kind: string): boolean {
194
+ return daq.meta.drivers.find(d => d.kind === kind)?.status === 'planned'
195
+ }
196
+
197
+ // ---------- 设备绑定 ----------
198
+ const bindDeviceId = ref('')
199
+ const boundDeviceName = computed(() => {
200
+ const ids = node.value?.deviceIds ?? (node.value?.deviceBindingId ? [node.value.deviceBindingId] : [])
201
+ return ids
202
+ .map(id => deviceTwins.twins.find(t => t.id === id)?.name ?? `${id.slice(0, 8)}…`)
203
+ .join(' / ')
204
+ })
205
+ function onBindToggle(): void {
206
+ void daq.bindNode(nodeId.value, node.value?.deviceBindingId ? null : (bindDeviceId.value || null))
207
+ }
208
+ const availableDevices = computed(() =>
209
+ deviceTwins.twins.filter(t => t.kind !== 'daq' && t.id !== node.value?.id),
210
+ )
211
+
212
+ // ---------- 历史(时序库) ----------
213
+ type ChartRow = { at: number, value?: number, avg?: number, min?: number, max?: number }
214
+ const historyPoints = ref<ChartRow[]>([])
215
+ const bucketMs = ref<number>(15000)
216
+ const histLoading = ref(false)
217
+ const BUCKETS = [
218
+ { label: '1s 桶', ms: 1000 },
219
+ { label: '5s 桶', ms: 5000 },
220
+ { label: '15s 桶(默认)', ms: 15000 },
221
+ { label: '30s 桶', ms: 30000 },
222
+ { label: '1min 桶', ms: 60000 },
223
+ ]
224
+ async function loadHistory(): Promise<void> {
225
+ if (!nodeId.value) return
226
+ histLoading.value = true
227
+ try {
228
+ const pts = await daq.samplesOf(nodeId.value, {
229
+ toMs: Date.now(),
230
+ bucketMs: bucketMs.value || undefined,
231
+ limit: 400,
232
+ })
233
+ // 接口 DESC 返回 → 图表时间正序
234
+ historyPoints.value = [...pts].reverse() as ChartRow[]
235
+ }
236
+ finally {
237
+ histLoading.value = false
238
+ drawChart()
239
+ }
240
+ }
241
+
242
+ const chartCanvas = ref<HTMLCanvasElement | null>(null)
243
+ const trendColor = '#35e0a0'
244
+ const gridColor = 'rgba(120,135,160,0.18)'
245
+ const inkColor = 'rgba(140,155,175,0.8)'
246
+ function drawChart(): void {
247
+ const cv = chartCanvas.value
248
+ if (!cv) return
249
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
250
+ const w = cv.clientWidth
251
+ const h = cv.clientHeight
252
+ if (cv.width !== w * dpr || cv.height !== h * dpr) {
253
+ cv.width = w * dpr
254
+ cv.height = h * dpr
255
+ }
256
+ const ctx = cv.getContext('2d')
257
+ if (!ctx) return
258
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
259
+ ctx.clearRect(0, 0, w, h)
260
+ for (let i = 1; i < 4; i++) {
261
+ ctx.strokeStyle = gridColor
262
+ ctx.beginPath()
263
+ ctx.moveTo(0, (h / 4) * i)
264
+ ctx.lineTo(w, (h / 4) * i)
265
+ ctx.stroke()
266
+ }
267
+ const rows = historyPoints.value
268
+ if (rows.length < 2) return
269
+ let lo = Number.POSITIVE_INFINITY
270
+ let hi = Number.NEGATIVE_INFINITY
271
+ for (const r of rows) {
272
+ const v = r.value ?? r.avg ?? r.min ?? 0
273
+ lo = Math.min(lo, v)
274
+ hi = Math.max(hi, v)
275
+ }
276
+ const span = Math.max(hi - lo, 1e-9)
277
+ ctx.strokeStyle = inkColor
278
+ ctx.font = '10px monospace'
279
+ ctx.fillStyle = inkColor
280
+ ctx.fillText(hi.toFixed(2), 4, 11)
281
+ ctx.fillText(lo.toFixed(2), 4, h - 4)
282
+ ctx.strokeStyle = trendColor
283
+ ctx.lineWidth = 1.6
284
+ ctx.beginPath()
285
+ rows.forEach((r, i) => {
286
+ const x = (i / (rows.length - 1)) * w
287
+ const y = h - (((r.value ?? r.avg ?? 0) - lo) / span) * (h - 16) - 8
288
+ if (i === 0) ctx.moveTo(x, y)
289
+ else ctx.lineTo(x, y)
290
+ })
291
+ ctx.stroke()
292
+ }
293
+
294
+ /** live 趋势(WS hist 环形缓冲;独立小画布,与历史图互补) */
295
+ const liveCanvas = ref<HTMLCanvasElement | null>(null)
296
+ let uiTimer: ReturnType<typeof setInterval> | null = null
297
+ function drawLive(): void {
298
+ const cv = liveCanvas.value
299
+ const n = node.value
300
+ if (!cv || !n || n.hist.length < 2) return
301
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
302
+ const w = cv.clientWidth
303
+ const h = cv.clientHeight
304
+ if (cv.width !== w * dpr || cv.height !== h * dpr) {
305
+ cv.width = w * dpr
306
+ cv.height = h * dpr
307
+ }
308
+ const ctx = cv.getContext('2d')
309
+ if (!ctx) return
310
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
311
+ ctx.clearRect(0, 0, w, h)
312
+ const hi = Math.max(...n.hist)
313
+ const lo = Math.min(...n.hist)
314
+ const span = Math.max(hi - lo, 1e-9)
315
+ ctx.strokeStyle = 'rgba(65,200,244,0.9)'
316
+ ctx.lineWidth = 1.5
317
+ ctx.beginPath()
318
+ n.hist.forEach((v, i) => {
319
+ const x = (i / (n.hist.length - 1)) * w
320
+ const y = h - ((v - lo) / span) * (h - 6) - 3
321
+ if (i === 0) ctx.moveTo(x, y)
322
+ else ctx.lineTo(x, y)
323
+ })
324
+ ctx.stroke()
325
+ }
326
+ onMounted(() => {
327
+ void loadHistory()
328
+ // live 趋势重绘循环(仅浏览器;SSR 安全)
329
+ uiTimer = setInterval(() => {
330
+ drawLive()
331
+ }, 800)
332
+ })
333
+ onBeforeUnmount(() => {
334
+ if (uiTimer) clearInterval(uiTimer)
335
+ })
336
+
337
+ watch(bucketMs, () => void loadHistory())
338
+ </script>
339
+
340
+ <template>
341
+ <div class="page">
342
+ <p class="aw-kicker">
343
+ <NuxtLink
344
+ to="/daq"
345
+ class="back"
346
+ >{{ $t('daqDetail.k1emg364001') }}</NuxtLink> / NODE {{ nodeId.slice(0, 8).toUpperCase() }}
347
+ </p>
348
+ <div class="aw-page-head">
349
+ <h1>{{ node?.name ?? nodeId }}</h1>
350
+ <span
351
+ class="st-pill"
352
+ :class="[effectiveState()]"
353
+ >{{ stateLabel[effectiveState()] }}</span>
354
+ </div>
355
+
356
+ <p
357
+ v-if="!node && daq.error"
358
+ class="err"
359
+ >
360
+ {{ daq.error }}(<NuxtLink to="/workshop">{{ $t('daqDetail.k1bhhheq002') }}</NuxtLink>)
361
+ </p>
362
+
363
+ <div
364
+ v-if="node"
365
+ class="grid"
366
+ >
367
+ <!-- 左列:实时状态卡 -->
368
+ <section class="col-live">
369
+ <div class="aw-tile value-card">
370
+ <p class="aw-kicker">
371
+ {{ $t('daqDetail.k1fx2vik032') }} {{ tpl?.ch ?? '-' }}
372
+ </p>
373
+ <div class="big-val mono">
374
+ {{ node.value != null ? node.value.toFixed(node.decimals) : '--' }}<small>{{ node.unit }}</small>
375
+ </div>
376
+ <canvas
377
+ ref="liveCanvas"
378
+ class="live-canvas"
379
+ />
380
+ <dl class="facts mono">
381
+ <div>
382
+ <dt>{{ $t('daqDetail.k4a0la003') }}</dt>
383
+ <dd>{{ node.driver }}{{ driverPlanned(node.driver) ? $t('daqDetail.kz8zr9v035') : '' }}</dd>
384
+ </div>
385
+ <div>
386
+ <dt>{{ $t('daqDetail.k3xg3w004') }}</dt>
387
+ <dd>{{ node.intervalMs ?? $t('daqDetail.k9vnp9h053', { p0: daq.controller.defaultIntervalMs }) }}</dd>
388
+ </div>
389
+ <div>
390
+ <dt>{{ $t('daqDetail.k3w6td005') }}</dt>
391
+ <dd>{{ publishLabel(node.publishIntervalMs) }}</dd>
392
+ </div>
393
+ <div>
394
+ <dt>{{ $t('daqDetail.k3x5tpx006') }}</dt>
395
+ <dd>{{ node.warnLow ?? '-∞' }} ~ {{ node.warnHigh ?? '+∞' }}</dd>
396
+ </div>
397
+ <div>
398
+ <dt>{{ $t('daqDetail.k1hjj0jf007') }}</dt>
399
+ <dd>{{ node.min }} ~ {{ node.max }}</dd>
400
+ </div>
401
+ <div>
402
+ <dt>{{ $t('daqDetail.k1i8rtqt008') }}</dt>
403
+ <dd>{{ boundDeviceName || $t('daqDetail.k3own4q036') }}</dd>
404
+ </div>
405
+ </dl>
406
+ </div>
407
+ </section>
408
+
409
+ <!-- 中列:参数控制 -->
410
+ <section class="col-form aw-tile pad">
411
+ <h3 class="sec">
412
+ {{ $t('daqDetail.kd9d69j009') }}
413
+ </h3>
414
+ <form
415
+ class="form-grid"
416
+ @submit.prevent="saveParams"
417
+ >
418
+ <label class="field">
419
+ <span>{{ $t('daqDetail.k1iw7tbf010') }}</span>
420
+ <button
421
+ type="button"
422
+ class="toggle"
423
+ :class="{ on: form.enabled }"
424
+ @click="form.enabled = !form.enabled"
425
+ >
426
+ {{ form.enabled ? $t('daqDetail.k3w3j8f037') : $t('daqDetail.k3n5aaj045') }}
427
+ </button>
428
+ </label>
429
+ <label class="field">
430
+ <span>{{ $t('daqDetail.k12k23od011') }}</span>
431
+ <select
432
+ v-model="form.lineId"
433
+ class="input"
434
+ >
435
+ <option value="">
436
+ {{ $t('daqDetail.k3ootr6012') }}
437
+ </option>
438
+ <option
439
+ v-for="l in dcw.lines"
440
+ :key="l.id"
441
+ :value="l.id"
442
+ >
443
+ {{ l.name }}
444
+ </option>
445
+ </select>
446
+ </label>
447
+ <label class="field">
448
+ <span>{{ $t('daqDetail.k1l6smxo013') }}</span>
449
+ <select
450
+ v-model="form.driver"
451
+ class="input"
452
+ >
453
+ <option
454
+ v-for="d in DAQ_DRIVERS"
455
+ :key="d.kind"
456
+ :value="d.kind"
457
+ :disabled="d.status === 'planned'"
458
+ >
459
+ {{ d.label }}{{ d.status === 'planned' ? $t('daqDetail.kz8zr9v035') : '' }}
460
+ </option>
461
+ </select>
462
+ </label>
463
+ <label class="field row">
464
+ <button
465
+ type="button"
466
+ class="toggle slim"
467
+ :class="{ on: form.followGlobal }"
468
+ @click="form.followGlobal = !form.followGlobal"
469
+ >
470
+ {{ form.followGlobal ? $t('daqDetail.k1k2ueyq038') : $t('daqDetail.k1cgi8fy046') }}
471
+ </button>
472
+ <input
473
+ v-model.number="form.intervalMs"
474
+ type="number"
475
+ min="1000"
476
+ max="60000"
477
+ step="500"
478
+ class="input"
479
+ :disabled="form.followGlobal"
480
+ ><small>ms</small>
481
+ </label>
482
+ <label class="field row">
483
+ <button
484
+ type="button"
485
+ class="toggle slim"
486
+ :class="{ on: form.calKind === 'linear' }"
487
+ title="采集标定 decoder:物理值 = scale × PLC采集值 + offset"
488
+ @click="form.calKind = form.calKind === 'linear' ? 'none' : 'linear'"
489
+ >
490
+ {{ form.calKind === 'linear' ? $t('daqDetail.k1eru4r1039') : $t('daqDetail.k3oktae047') }}
491
+ </button>
492
+ <input
493
+ v-model.number="form.calScale"
494
+ type="number"
495
+ step="0.1"
496
+ class="input"
497
+ :disabled="form.calKind !== 'linear'"
498
+ title="decode 斜率(≠0)"
499
+ >
500
+ <input
501
+ v-model.number="form.calOffset"
502
+ type="number"
503
+ step="0.1"
504
+ class="input"
505
+ :disabled="form.calKind !== 'linear'"
506
+ title="decode 截距"
507
+ ><small>decoder</small>
508
+ </label>
509
+ <label class="field row">
510
+ <button
511
+ type="button"
512
+ class="toggle slim"
513
+ :class="{ on: form.publishFollow }"
514
+ @click="form.publishFollow = !form.publishFollow"
515
+ >
516
+ {{ form.publishFollow ? $t('daqDetail.k1k2t5o7040') : $t('daqDetail.k1cggz5f048') }}
517
+ </button>
518
+ <span
519
+ class="toggle slim"
520
+ :class="{ on: form.publishEveryFrame }"
521
+ style="cursor: pointer;"
522
+ @click="form.publishEveryFrame = !form.publishEveryFrame"
523
+ >{{ form.publishEveryFrame ? $t('daqDetail.k41mvv041') : $t('daqDetail.k3n42dj049') }}</span>
524
+ <input
525
+ v-model.number="form.publishMs"
526
+ type="number"
527
+ min="0"
528
+ max="60000"
529
+ step="100"
530
+ class="input"
531
+ :disabled="form.publishFollow || form.publishEveryFrame"
532
+ ><small>ms</small>
533
+ </label>
534
+ <label class="field">
535
+ <span>{{ $t('daqDetail.k3x4ef014') }}</span>
536
+ <input
537
+ v-model="form.unit"
538
+ class="input"
539
+ >
540
+ </label>
541
+ <label class="field">
542
+ <span>{{ $t('daqDetail.k3mxmcx015') }}</span>
543
+ <input
544
+ v-model.number="form.decimals"
545
+ type="number"
546
+ min="0"
547
+ max="6"
548
+ class="input"
549
+ >
550
+ </label>
551
+ <div class="field-row">
552
+ <label class="field">
553
+ <span>{{ $t('daqDetail.k1l9jv5m016') }}</span>
554
+ <input
555
+ v-model.number="form.min"
556
+ type="number"
557
+ step="any"
558
+ class="input"
559
+ >
560
+ </label>
561
+ <label class="field">
562
+ <span>{{ $t('daqDetail.k1md63hm017') }}</span>
563
+ <input
564
+ v-model.number="form.warnLow"
565
+ type="number"
566
+ step="any"
567
+ class="input"
568
+ >
569
+ </label>
570
+ <label class="field">
571
+ <span>{{ $t('daqDetail.k1md63gp018') }}</span>
572
+ <input
573
+ v-model.number="form.warnHigh"
574
+ type="number"
575
+ step="any"
576
+ class="input"
577
+ >
578
+ </label>
579
+ <label class="field">
580
+ <span>{{ $t('daqDetail.k1l9jv4p019') }}</span>
581
+ <input
582
+ v-model.number="form.max"
583
+ type="number"
584
+ step="any"
585
+ class="input"
586
+ >
587
+ </label>
588
+ </div>
589
+ <button
590
+ class="aw-pill"
591
+ type="submit"
592
+ :disabled="saving"
593
+ >
594
+ {{ saving ? $t('daqDetail.k1as0g50042') : $t('daqDetail.ksn9cgo050') }}
595
+ </button>
596
+ </form>
597
+
598
+ <!-- 驱动连接参数(mock 空;真实协议 schema 动态表单 + 测试连接) -->
599
+ <template v-if="driverFields.length">
600
+ <h3 class="sec mt">
601
+ {{ $t('daqDetail.k1p9gioa033') }} {{ DAQ_DRIVERS.find(d => d.kind === form.driver)?.label }}
602
+ </h3>
603
+ <div class="driver-grid">
604
+ <label
605
+ v-for="f in driverFields"
606
+ :key="f.key"
607
+ class="field"
608
+ >
609
+ <span>{{ f.label }}<em v-if="f.required">*</em></span>
610
+ <select
611
+ v-if="f.type === 'select'"
612
+ v-model="driverCfg[f.key]"
613
+ class="input"
614
+ >
615
+ <option
616
+ v-for="o in f.options"
617
+ :key="o.value"
618
+ :value="o.value"
619
+ >
620
+ {{ o.label }}
621
+ </option>
622
+ </select>
623
+ <input
624
+ v-else
625
+ v-model="driverCfg[f.key]"
626
+ :type="f.type === 'number' ? 'number' : 'text'"
627
+ :placeholder="f.placeholder"
628
+ class="input"
629
+ >
630
+ </label>
631
+ </div>
632
+ <div class="test-row">
633
+ <button
634
+ class="pill-btn"
635
+ :disabled="testing"
636
+ @click="doTest"
637
+ >
638
+ {{ testing ? $t('daqDetail.k1fsh720043') : $t('daqDetail.k1fstglk051') }}
639
+ </button>
640
+ <span
641
+ v-if="testResult"
642
+ class="test-out mono"
643
+ :class="testResult.ok ? 'ok' : 'bad'"
644
+ >{{ testResult.ok ? '✓' : '✗' }} {{ testResult.message }}</span>
645
+ </div>
646
+ </template>
647
+
648
+ <h3 class="sec mt">
649
+ {{ $t('daqDetail.k1ie6qju020') }}
650
+ </h3>
651
+ <div class="bind-row">
652
+ <select
653
+ v-model="bindDeviceId"
654
+ class="input grow"
655
+ >
656
+ <option value="">
657
+ {{ $t('daqDetail.kjobzu3021') }}
658
+ </option>
659
+ <option
660
+ v-for="dv in availableDevices"
661
+ :key="dv.id"
662
+ :value="dv.id"
663
+ >
664
+ {{ dv.name }}
665
+ </option>
666
+ </select>
667
+ <button
668
+ class="pill-btn"
669
+ @click="onBindToggle"
670
+ >
671
+ {{ boundDeviceName ? $t('daqDetail.k479eh044') : $t('daqDetail.k452a8052') }}
672
+ </button>
673
+ </div>
674
+ </section>
675
+
676
+ <!-- 右列:时序库历史 -->
677
+ <section class="col-hist aw-tile pad">
678
+ <div class="hist-hd">
679
+ <h3 class="sec">
680
+ {{ $t('daqDetail.k1i535i5034') }}{{ daq.meta.tsdb }})
681
+ </h3>
682
+ <div class="hist-ctl mono">
683
+ <select
684
+ v-model.number="bucketMs"
685
+ class="input"
686
+ >
687
+ <option
688
+ v-for="b in BUCKETS"
689
+ :key="b.ms"
690
+ :value="b.ms"
691
+ >
692
+ {{ b.label }}
693
+ </option>
694
+ </select>
695
+ <button
696
+ class="pill-btn"
697
+ @click="loadHistory"
698
+ >
699
+ {{ $t('daqDetail.k3x1jg022') }}
700
+ </button>
701
+ </div>
702
+ </div>
703
+ <canvas
704
+ ref="chartCanvas"
705
+ class="hist-canvas"
706
+ />
707
+ <table class="raw-table mono">
708
+ <thead>
709
+ <tr><th>{{ $t('daqDetail.k40vsf023') }}</th><th>{{ $t('daqDetail.k48v5024') }}</th><th>{{ $t('daqDetail.k4bza025') }}</th></tr>
710
+ </thead>
711
+ <tbody>
712
+ <tr
713
+ v-for="(p, i) in historyPoints.filter(r => r.value != null).slice(-12).reverse()"
714
+ :key="i"
715
+ >
716
+ <td>{{ new Date(p.at).toLocaleTimeString('zh-CN', { hour12: false }) }}</td>
717
+ <td>{{ p.value!.toFixed(node?.decimals ?? 2) }}</td>
718
+ <td>{{ p.state }}</td>
719
+ </tr>
720
+ </tbody>
721
+ </table>
722
+ </section>
723
+ </div>
724
+
725
+ <!-- 帧视图(v2 多形态信号:向量轮廓 / 图像画廊;scalar 节点不渲染) -->
726
+ <section
727
+ v-if="isFrameNode"
728
+ class="aw-tile pad frames-tile"
729
+ >
730
+ <div class="hist-hd">
731
+ <h3 class="sec">
732
+ {{ signalKind === 'image' ? $t('daqDetail.kfrm0001') : $t('daqDetail.kfrm0002') }}
733
+ <span class="mono frames-n">{{ frames.length }}</span>
734
+ </h3>
735
+ <button
736
+ class="pill-btn"
737
+ @click="loadFrames"
738
+ >
739
+ {{ $t('daqDetail.k3x1jg022') }}
740
+ </button>
741
+ </div>
742
+
743
+ <!-- 向量:最近帧轮廓 + 帧表 -->
744
+ <template v-if="signalKind === 'vector'">
745
+ <svg
746
+ class="vec-svg"
747
+ :viewBox="`0 0 ${VEC_W} ${VEC_H}`"
748
+ preserveAspectRatio="none"
749
+ >
750
+ <polyline
751
+ :points="vecPath"
752
+ fill="none"
753
+ stroke="var(--accent)"
754
+ stroke-width="1.5"
755
+ />
756
+ </svg>
757
+ <table class="raw-table mono">
758
+ <thead>
759
+ <tr><th>{{ $t('daqDetail.k40vsf023') }}</th><th>{{ $t('daqDetail.kfrm0004') }}</th><th>{{ $t('daqDetail.kfrm0005') }}</th></tr>
760
+ </thead>
761
+ <tbody>
762
+ <tr
763
+ v-for="f in frames.filter(x => x.kind === 'vector').slice(0, 12)"
764
+ :key="f.at"
765
+ >
766
+ <td>{{ timeOf(f.at) }}</td>
767
+ <td>{{ f.points?.length ?? 0 }}</td>
768
+ <td>{{ metricsBrief(f) }}</td>
769
+ </tr>
770
+ </tbody>
771
+ </table>
772
+ </template>
773
+
774
+ <!-- 图像:缩略图画gallery(懒加载;点击看原图) -->
775
+ <template v-else>
776
+ <div
777
+ v-if="frames.length"
778
+ class="gal"
779
+ >
780
+ <figure
781
+ v-for="f in frames"
782
+ :key="f.at"
783
+ class="gal-item"
784
+ :title="$t('daqDetail.kfrm0006')"
785
+ @click="fullFrame = f"
786
+ >
787
+ <img
788
+ :src="f.thumbUrl"
789
+ loading="lazy"
790
+ alt="daq frame"
791
+ >
792
+ <figcaption class="mono">
793
+ {{ timeOf(f.at) }} · {{ metricsBrief(f) }}
794
+ </figcaption>
795
+ </figure>
796
+ </div>
797
+ <p
798
+ v-else
799
+ class="frames-empty"
800
+ >
801
+ {{ $t('daqDetail.kfrm0003') }}
802
+ </p>
803
+ <div
804
+ v-if="fullFrame"
805
+ class="gal-full"
806
+ @click="fullFrame = null"
807
+ >
808
+ <img
809
+ :src="fullFrame.contentUrl ?? fullFrame.thumbUrl"
810
+ alt="daq frame full"
811
+ >
812
+ </div>
813
+ </template>
814
+ </section>
815
+ </div>
816
+ </template>
817
+
818
+ <style scoped>
819
+ .page { padding: 4px; }
820
+ .back { color: var(--ink-faint); text-decoration: none; }
821
+ .back:hover { color: var(--accent); }
822
+ .aw-page-head h1 { margin: 2px 0 4px; font-size: 26px; font-weight: 400; letter-spacing: -0.01em; }
823
+ .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
824
+ .sec { margin: 0 0 10px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }
825
+ .mt { margin-top: 22px; }
826
+ .err { margin-top: 14px; font-size: 13px; color: var(--tone-danger-dot); }
827
+
828
+ .grid {
829
+ display: grid;
830
+ gap: 14px;
831
+ align-items: start;
832
+ grid-template-columns: minmax(260px, 340px) minmax(320px, 1fr) minmax(280px, 380px);
833
+ }
834
+ @media (max-width: 1100px) {
835
+ .grid { grid-template-columns: 1fr; }
836
+ }
837
+ .pad { padding: 16px 18px; }
838
+
839
+ /* 实时值卡 */
840
+ .value-card { padding: 16px 18px; }
841
+ .big-val { margin: 8px 0 10px; font-size: 40px; line-height: 1.1; color: var(--accent); }
842
+ .big-val small { margin-left: 8px; font-size: 15px; color: var(--ink-faint); }
843
+ .live-canvas { width: 100%; height: 64px; border: 1px solid var(--line); border-radius: var(--radius-chip); background: var(--paper-deep); }
844
+ .facts { display: flex; flex-direction: column; gap: 4px; margin: 14px 0 0; }
845
+ .facts div { display: flex; justify-content: space-between; font-size: 11.5px; }
846
+ .facts dt { color: var(--ink-faint); }
847
+ .facts dd { margin: 0; color: var(--ink); }
848
+
849
+ /* 参数表单 */
850
+ .form-grid { display: flex; flex-direction: column; gap: 10px; }
851
+ .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-faint); flex: 1; }
852
+ .field.row { flex-direction: row; align-items: center; gap: 8px; }
853
+ .field.row small { color: var(--ink-faint); }
854
+ .field-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
855
+ .input {
856
+ width: 100%;
857
+ padding: 6px 9px;
858
+ font-size: 12.5px;
859
+ color: var(--ink);
860
+ background: var(--paper-raised);
861
+ border: 1px solid var(--line-strong);
862
+ border-radius: var(--radius-chip);
863
+ }
864
+ .input:disabled { opacity: 0.45; }
865
+ .grow { flex: 1 1 auto; }
866
+ .toggle {
867
+ align-self: flex-start;
868
+ padding: 6px 12px;
869
+ font-size: 12px;
870
+ cursor: pointer;
871
+ color: var(--tone-neutral-dot);
872
+ background: var(--tone-neutral-bg);
873
+ border: 0;
874
+ border-radius: var(--radius-chip);
875
+ }
876
+ .toggle.on { color: var(--tone-success-dot); background: var(--tone-success-bg); }
877
+ .toggle.slim { padding: 6px 10px; font-size: 11.5px; }
878
+ .bind-row { display: flex; gap: 8px; }
879
+ .driver-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
880
+ .field em { margin-left: 2px; font-style: normal; color: var(--tone-danger-dot); }
881
+ .test-row { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
882
+ .test-out { font-size: 11px; }
883
+ .test-out.ok { color: var(--tone-success-dot); }
884
+ .test-out.bad { color: var(--tone-danger-dot); }
885
+
886
+ /* 历史 */
887
+ .col-hist { min-width: 0; }
888
+ .hist-hd { display: flex; gap: 10px; align-items: center; justify-content: space-between; margin-bottom: 10px; }
889
+ .hist-ctl { display: flex; gap: 6px; }
890
+ .hist-canvas { width: 100%; height: 130px; border: 1px solid var(--line); border-radius: var(--radius-chip); background: var(--paper-deep); }
891
+ .raw-table { width: 100%; margin-top: 10px; font-size: 11px; border-collapse: collapse; }
892
+ .raw-table th, .raw-table td { padding: 4px 6px; text-align: left; border-bottom: 1px solid var(--divider-hair); }
893
+ .raw-table th { font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink-fainter); }
894
+
895
+ .st-pill {
896
+ display: inline-block;
897
+ padding: 3px 11px;
898
+ font-family: var(--font-mono);
899
+ font-size: 11px;
900
+ letter-spacing: 0.04em;
901
+ border-radius: var(--radius-pill);
902
+ }
903
+ .st-pill.ok { color: var(--tone-success-dot); background: var(--tone-success-bg); }
904
+ .st-pill.warn { color: var(--tone-warning-dot); background: var(--tone-warning-bg); }
905
+ .st-pill.alarm { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
906
+ .st-pill.offline { color: var(--tone-neutral-dot); background: var(--tone-neutral-bg); }
907
+
908
+ /* 帧视图(v2 多形态) */
909
+ .frames-tile { margin-top: 14px; }
910
+ .frames-n { margin-left: 8px; color: var(--ink-faint); }
911
+ .vec-svg { width: 100%; height: 96px; display: block; background: var(--tone-neutral-bg); border-radius: 6px; margin-bottom: 10px; }
912
+ .gal { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
913
+ .gal-item { margin: 0; cursor: zoom-in; }
914
+ .gal-item img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 6px; display: block; background: var(--tone-neutral-bg); }
915
+ .gal-item figcaption { margin-top: 4px; font-size: 11px; color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
916
+ .frames-empty { margin: 8px 0; font-size: 13px; color: var(--ink-faint); }
917
+ .gal-full { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 72%); cursor: zoom-out; }
918
+ .gal-full img { max-width: 92vw; max-height: 92vh; border-radius: 8px; }
919
+ </style>