agentworkshop 0.7.41 → 0.7.44

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 (265) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/B8uy9iRW.js +1 -0
  3. package/.output/public/_nuxt/BGTYYQH-.js +1 -0
  4. package/.output/public/_nuxt/BUmHEJ77.js +2 -0
  5. package/.output/public/_nuxt/BY7YrV_D.js +1 -0
  6. package/.output/public/_nuxt/Bqcgz95s.js +18 -0
  7. package/.output/public/_nuxt/C7UlqPeD.js +1 -0
  8. package/.output/public/_nuxt/CTmDn-YP.js +1 -0
  9. package/.output/public/_nuxt/Cs8pVeVG.js +1 -0
  10. package/.output/public/_nuxt/DKySmNPI.js +14 -0
  11. package/.output/public/_nuxt/Di0NY_ht.js +1 -0
  12. package/.output/public/_nuxt/Du9v6qGB.js +1 -0
  13. package/.output/public/_nuxt/{DGbd-UiG.js → EHlEMRii.js} +1 -1
  14. package/.output/public/_nuxt/Eke1fs-U.js +1 -0
  15. package/.output/public/_nuxt/{DeJykBFf.js → H0BcqZtT.js} +1 -1
  16. package/.output/public/_nuxt/{C4oW1HsE.js → HSYEkkce.js} +1 -1
  17. package/.output/public/_nuxt/{D_OTvQza.js → J9oU00D1.js} +1 -1
  18. package/.output/public/_nuxt/OmpTerminalPanel.BxR5-lvi.css +1 -0
  19. package/.output/public/_nuxt/SSyAPaHc.js +1 -0
  20. package/.output/public/_nuxt/TownView.li4EMQxK.css +1 -0
  21. package/.output/public/_nuxt/VdLk4jLE2.js +1 -0
  22. package/.output/public/_nuxt/_id_.BhsXoEuk.css +1 -0
  23. package/.output/public/_nuxt/_id_.DQyj-WKN.css +1 -0
  24. package/.output/public/_nuxt/{_wsId_.B4WiqkIS.css → _wsId_.CTx-BlfF.css} +1 -1
  25. package/.output/public/_nuxt/aShsv_rG.js +19 -0
  26. package/.output/public/_nuxt/{yF0aYMF1.js → aesgXKSr.js} +3 -3
  27. package/.output/public/_nuxt/agents.B21nUEUX.css +1 -0
  28. package/.output/public/_nuxt/aml.ekTjxFkf.css +1 -0
  29. package/.output/public/_nuxt/builds/latest.json +1 -1
  30. package/.output/public/_nuxt/builds/meta/b5506ef0-f48e-4f14-a6c0-d65e505d0b09.json +1 -0
  31. package/.output/public/_nuxt/channel-templates.DplM1pJ3.css +1 -0
  32. package/.output/public/_nuxt/daq.BdVmoIfe.css +1 -0
  33. package/.output/public/_nuxt/dcw.DCgyzpWV.css +1 -0
  34. package/.output/public/_nuxt/{default.J32SgqFY.css → default.Bh3L2HU8.css} +1 -1
  35. package/.output/public/_nuxt/lHAxdSsj.js +1 -0
  36. package/.output/public/_nuxt/pages.j9rzRocr.css +1 -0
  37. package/.output/public/_nuxt/plugins.DhiZbHU8.css +1 -0
  38. package/.output/public/_nuxt/settings.BRx9rIDg.css +1 -0
  39. package/.output/public/_nuxt/teams.BLlOPB_Z.css +1 -0
  40. package/.output/public/_nuxt/tokens.De8xzszn.css +1 -0
  41. package/.output/public/_nuxt/workshop.D1jFNkrn.css +1 -0
  42. package/.output/server/chunks/_/nitro.mjs +748 -733
  43. package/.output/server/chunks/build/{OmpTerminalPanel-Cu_s4i0A.mjs → OmpTerminalPanel-CVvxpsF7.mjs} +15 -15
  44. package/.output/server/chunks/build/{OmpTerminalPanel-Cu_s4i0A.mjs.map → OmpTerminalPanel-CVvxpsF7.mjs.map} +1 -1
  45. package/.output/server/chunks/build/{TownView--otsnr_7.mjs → TownView-CJ6IsDCc.mjs} +226 -225
  46. package/.output/server/chunks/build/{TownView--otsnr_7.mjs.map → TownView-CJ6IsDCc.mjs.map} +1 -1
  47. package/.output/server/chunks/build/{_id_-Brv-JSvr.mjs → _id_-CVD_H0ir.mjs} +143 -142
  48. package/.output/server/chunks/build/_id_-CVD_H0ir.mjs.map +1 -0
  49. package/.output/server/chunks/build/{_id_-D9pl1OZy.mjs → _id_-CtxMb3qp.mjs} +41 -41
  50. package/.output/server/chunks/build/_id_-CtxMb3qp.mjs.map +1 -0
  51. package/.output/server/chunks/build/{_wsId_-BASfDf0s.mjs → _wsId_-DpY3T-md.mjs} +514 -444
  52. package/.output/server/chunks/build/{_wsId_-BASfDf0s.mjs.map → _wsId_-DpY3T-md.mjs.map} +1 -1
  53. package/.output/server/chunks/build/{agents-CLQqJF2y.mjs → agents-DsCkuXDV.mjs} +49 -46
  54. package/.output/server/chunks/build/agents-DsCkuXDV.mjs.map +1 -0
  55. package/.output/server/chunks/build/{aml-_7FdFL2p.mjs → aml-CT0xKGKI.mjs} +122 -105
  56. package/.output/server/chunks/build/aml-CT0xKGKI.mjs.map +1 -0
  57. package/.output/server/chunks/build/builtin-catalog-edpDcgfL.mjs +117 -0
  58. package/.output/server/chunks/build/builtin-catalog-edpDcgfL.mjs.map +1 -0
  59. package/.output/server/chunks/build/{channel-templates-p6SZNjlM.mjs → channel-templates-Dla9FbYa.mjs} +49 -37
  60. package/.output/server/chunks/build/channel-templates-Dla9FbYa.mjs.map +1 -0
  61. package/.output/server/chunks/build/{daq-DkMvD8sg.mjs → daq-DWWSKGAV.mjs} +89 -88
  62. package/.output/server/chunks/build/daq-DWWSKGAV.mjs.map +1 -0
  63. package/.output/server/chunks/build/{dcw-C1jgVJkO.mjs → dcw-CT8k17_E.mjs} +44 -43
  64. package/.output/server/chunks/build/dcw-CT8k17_E.mjs.map +1 -0
  65. package/.output/server/chunks/build/{default-BE4r0FZN.mjs → default-D3525UFX.mjs} +16 -15
  66. package/.output/server/chunks/build/{default-BE4r0FZN.mjs.map → default-D3525UFX.mjs.map} +1 -1
  67. package/.output/server/chunks/build/{monitor-C0okQaXz.mjs → monitor-Dh2Kvaq5.mjs} +2 -2
  68. package/.output/server/chunks/build/{monitor-C0okQaXz.mjs.map → monitor-Dh2Kvaq5.mjs.map} +1 -1
  69. package/.output/server/chunks/build/{pages-B0bUWnC6.mjs → pages-BHcSBM2D.mjs} +24 -18
  70. package/.output/server/chunks/build/pages-BHcSBM2D.mjs.map +1 -0
  71. package/.output/server/chunks/build/{plugins-lGlUr1lj.mjs → plugins-BNgIi61W.mjs} +23 -17
  72. package/.output/server/chunks/build/plugins-BNgIi61W.mjs.map +1 -0
  73. package/.output/server/chunks/build/{settings-Ddd1qx2Z.mjs → settings-ipYBDWdn.mjs} +45 -44
  74. package/.output/server/chunks/build/settings-ipYBDWdn.mjs.map +1 -0
  75. package/.output/server/chunks/build/{teams-MEDLZdMn.mjs → teams-5lx-CwFa.mjs} +47 -44
  76. package/.output/server/chunks/build/teams-5lx-CwFa.mjs.map +1 -0
  77. package/.output/server/chunks/build/{tokens-C7fTM5C2.mjs → tokens-q-IDT8Io.mjs} +73 -53
  78. package/.output/server/chunks/build/tokens-q-IDT8Io.mjs.map +1 -0
  79. package/.output/server/chunks/build/{town-CoQdblbZ.mjs → town-QJWB5wng.mjs} +3 -2
  80. package/.output/server/chunks/build/town-QJWB5wng.mjs.map +1 -0
  81. package/.output/server/chunks/build/{workshop-BL5bhfs0.mjs → workshop-BIlCj_ZP.mjs} +45 -45
  82. package/.output/server/chunks/build/workshop-BIlCj_ZP.mjs.map +1 -0
  83. package/.output/server/chunks/routes/renderer.mjs +1 -1
  84. package/.output/server/chunks/virtual/entry.mjs +637 -86
  85. package/.output/server/chunks/virtual/entry.mjs.map +1 -1
  86. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  87. package/.output/server/package.json +1 -1
  88. package/README-zh.md +13 -3
  89. package/README.md +13 -3
  90. package/app/components/AppHeader.vue +961 -959
  91. package/app/components/workshop/A2aRpcDebugger.vue +237 -237
  92. package/app/components/workshop/AgentInspectorDrawer.vue +424 -424
  93. package/app/components/workshop/AgentLanesView.vue +998 -998
  94. package/app/components/workshop/ArtifactCard.vue +120 -120
  95. package/app/components/workshop/AssetLibrary.vue +452 -452
  96. package/app/components/workshop/ChannelSessionList.vue +812 -804
  97. package/app/components/workshop/CommandPalette.vue +269 -269
  98. package/app/components/workshop/Composer.vue +827 -827
  99. package/app/components/workshop/DeviceTwinPanel.vue +643 -643
  100. package/app/components/workshop/EventBlock.vue +602 -602
  101. package/app/components/workshop/FileSelectorModal.vue +188 -188
  102. package/app/components/workshop/InspectorPanel.vue +301 -301
  103. package/app/components/workshop/MemoryPanel.vue +364 -364
  104. package/app/components/workshop/MultiChannelView.vue +189 -189
  105. package/app/components/workshop/TaskBoardView.vue +686 -686
  106. package/app/components/workshop/TaskInspectorDrawer.vue +390 -390
  107. package/app/components/workshop/TranscriptTimeline.vue +601 -601
  108. package/app/components/workshop/blocks/ClusterArtifact.vue +61 -61
  109. package/app/components/workshop/blocks/ClusterError.vue +85 -85
  110. package/app/components/workshop/blocks/ClusterLife.vue +87 -87
  111. package/app/components/workshop/blocks/ClusterMember.vue +95 -93
  112. package/app/components/workshop/blocks/ClusterMemory.vue +84 -84
  113. package/app/components/workshop/blocks/ClusterOther.vue +67 -67
  114. package/app/components/workshop/blocks/ClusterStatus.vue +149 -147
  115. package/app/components/workshop/blocks/ClusterTool.vue +158 -158
  116. package/app/components/workshop/terminal/OmpTerminalPanel.vue +1011 -1011
  117. package/app/components/workshop/town/TownView.vue +56 -56
  118. package/app/middleware/locale-cookie.global.ts +22 -0
  119. package/app/pages/aml/index.vue +24 -2
  120. package/app/pages/daq/[id].vue +11 -11
  121. package/app/pages/daq/index.vue +2316 -2316
  122. package/app/pages/dcw/[id].vue +18 -19
  123. package/app/pages/dcw/index.vue +3 -3
  124. package/app/pages/index.vue +987 -981
  125. package/app/pages/plugins/index.vue +317 -311
  126. package/app/pages/settings.vue +1564 -1562
  127. package/app/pages/tokens.vue +15 -15
  128. package/app/pages/workshop/agents.vue +612 -609
  129. package/app/pages/workshop/channel-templates.vue +18 -11
  130. package/app/pages/workshop/index.vue +668 -668
  131. package/app/pages/workshop/teams.vue +743 -740
  132. package/app/pages/workshop/w/[wsId].vue +704 -704
  133. package/app/plugins/locale-restore.client.ts +16 -1
  134. package/app/utils/builtin-catalog.ts +104 -0
  135. package/config.yml +170 -170
  136. package/docs/full-test-plan.md +2 -3
  137. package/i18n/locales/en.ts +293 -35
  138. package/i18n/locales/zh-CN.ts +265 -7
  139. package/package.json +2 -2
  140. package/scripts/_dbg-perms-e2e.mjs +132 -109
  141. package/scripts/_demo-agentteam-capture.mjs +278 -0
  142. package/scripts/_e2e-ui-functional.mjs +371 -0
  143. package/scripts/_i18n-en-cjk-scan.mjs +28 -0
  144. package/scripts/_i18n-en-verify.mjs +107 -0
  145. package/scripts/_i18n-extra-checks.mjs +53 -0
  146. package/scripts/_i18n-scan-hardcoded.mjs +180 -0
  147. package/scripts/_i18n-verify-keys.mjs +50 -0
  148. package/scripts/_loop-biax-campaign.mjs +15 -9
  149. package/scripts/_sweep-button-contrast.mjs +87 -0
  150. package/scripts/ui/record-demo.mjs +557 -0
  151. package/server/plugins-builtin/diag-bridge/index.mjs +608 -661
  152. package/server/plugins-builtin/rag-bridge/index.mjs +57 -133
  153. package/server/services/workshop/daq/drivers.ts +11 -0
  154. package/.output/public/_nuxt/88GOowgO2.js +0 -1
  155. package/.output/public/_nuxt/BN16aRdQ.js +0 -1
  156. package/.output/public/_nuxt/BWVhZntO.js +0 -1
  157. package/.output/public/_nuxt/BXxH0UQE.js +0 -1
  158. package/.output/public/_nuxt/BsROmY89.js +0 -1
  159. package/.output/public/_nuxt/C0ZrS1KL.js +0 -19
  160. package/.output/public/_nuxt/C4s_niy-.js +0 -14
  161. package/.output/public/_nuxt/D6mbFA-a.js +0 -1
  162. package/.output/public/_nuxt/DS8qj8EL.js +0 -1
  163. package/.output/public/_nuxt/DUjW5Yq5.js +0 -1
  164. package/.output/public/_nuxt/Dap5PmTL.js +0 -2
  165. package/.output/public/_nuxt/Dqw1lpr-.js +0 -1
  166. package/.output/public/_nuxt/DvLxILQP.js +0 -1
  167. package/.output/public/_nuxt/OmpTerminalPanel.B_Pl5Ynm.css +0 -1
  168. package/.output/public/_nuxt/TownView.f-YOdXRs.css +0 -1
  169. package/.output/public/_nuxt/_id_.D9ymtHB5.css +0 -1
  170. package/.output/public/_nuxt/_id_.jf-_8E_c.css +0 -1
  171. package/.output/public/_nuxt/agents.ZD0rVdHE.css +0 -1
  172. package/.output/public/_nuxt/aml.BN8ZEW4h.css +0 -1
  173. package/.output/public/_nuxt/builds/meta/78a295bb-2072-48a4-8b46-6d545fdae743.json +0 -1
  174. package/.output/public/_nuxt/channel-templates.BOtahzPi.css +0 -1
  175. package/.output/public/_nuxt/daq.DjSgY94l.css +0 -1
  176. package/.output/public/_nuxt/dcw.8Y7FqCVN.css +0 -1
  177. package/.output/public/_nuxt/eqq3wWQF.js +0 -18
  178. package/.output/public/_nuxt/pages.D7aSDlUI.css +0 -1
  179. package/.output/public/_nuxt/plugins.sb4nDGqr.css +0 -1
  180. package/.output/public/_nuxt/qr6R6Ha1.js +0 -1
  181. package/.output/public/_nuxt/settings.BTgR-NiE.css +0 -1
  182. package/.output/public/_nuxt/teams.CxnxKVVs.css +0 -1
  183. package/.output/public/_nuxt/tokens.BFY7qv4f.css +0 -1
  184. package/.output/public/_nuxt/workshop.CR_QzeAN.css +0 -1
  185. package/.output/server/chunks/build/_id_-Brv-JSvr.mjs.map +0 -1
  186. package/.output/server/chunks/build/_id_-D9pl1OZy.mjs.map +0 -1
  187. package/.output/server/chunks/build/agents-CLQqJF2y.mjs.map +0 -1
  188. package/.output/server/chunks/build/aml-_7FdFL2p.mjs.map +0 -1
  189. package/.output/server/chunks/build/channel-templates-p6SZNjlM.mjs.map +0 -1
  190. package/.output/server/chunks/build/daq-DkMvD8sg.mjs.map +0 -1
  191. package/.output/server/chunks/build/dcw-C1jgVJkO.mjs.map +0 -1
  192. package/.output/server/chunks/build/pages-B0bUWnC6.mjs.map +0 -1
  193. package/.output/server/chunks/build/plugins-lGlUr1lj.mjs.map +0 -1
  194. package/.output/server/chunks/build/settings-Ddd1qx2Z.mjs.map +0 -1
  195. package/.output/server/chunks/build/teams-MEDLZdMn.mjs.map +0 -1
  196. package/.output/server/chunks/build/tokens-C7fTM5C2.mjs.map +0 -1
  197. package/.output/server/chunks/build/town-CoQdblbZ.mjs.map +0 -1
  198. package/.output/server/chunks/build/workshop-BL5bhfs0.mjs.map +0 -1
  199. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.continue.json +0 -1
  200. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.json +0 -1
  201. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/054bb4bc4e4217a2ab0b0c6b663747214a9af464f5568ea60e784704e340bb0f.source +0 -153
  202. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/0554197c3d2bac203a1f5c48a5d52495d1336db1bd7aa4f4c19875810363e684.source +0 -153
  203. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/05c203d12a954fffba942e1d626e3f5fec05b18a5aef2bac003b7884a4b77406.source +0 -431
  204. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/145a6e6ba811af35aed31e0cc3211f53706a14980fa069ee9e3c1591139f96f0.source +0 -180
  205. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/1ae0fb281f97899c025e368ca73f41349b01fb5f85b19e08d7ebccff49095ed5.source +0 -112
  206. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/20ee9967aa0541c516db7575bdd4a1a745939bc7016bd10d7635903113edb8ec.source +0 -227
  207. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/233751843aeba9d4c5263f275b8102de00443c06f286918cbd23e0b6729e01aa.source +0 -131
  208. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/263c2e05b425e5c322e60c6475279f963bab3a873eb521b21df7fec935a46597.source +0 -160
  209. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/2ac5eaac28eaaee2178c4b32014df8e732f9dd432a285a872f5b7f31c1395fa4.source +0 -157
  210. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/33a0db0d2d95d2c11d0720090458e5fba1425376f5ceb5b422d1d3471be3226b.source +0 -348
  211. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/359e474ae83db35e4615d3728de6bf694b64d4dce9b536730a3b6518f9c0494a.source +0 -296
  212. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/37a4c3c31c4b70f6b719c53d1e9c4fcba603f31adfdd11bb6ef9f0d094923c56.source +0 -285
  213. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/461feca1c14a695eb8b0f17a6f95fe97eeceabc0510faaea4ba6d25bde147d8d.source +0 -217
  214. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/4cfadbda8591f29d5e1e230dc000e701b756e850441b3900fb596a4d2f6e98c2.source +0 -301
  215. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/50ca2810cf0b248c96948994c0c404531bbad6e553166b6816b3b03bfa0f75d2.source +0 -151
  216. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/52d9e8cb591a9ae86e238c3620a610cd386717bee222c0f37b25b2ac7e523dc4.source +0 -135
  217. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/6c87daafa4c006560a954636a97333f6123153c9d34d96203f7ee3a14e556357.source +0 -156
  218. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/77bc47854b577900621cacf1697f81fa2bc005af129d37614a97a3441c08f99f.source +0 -72
  219. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/7bbb789d3a527817118cf8399ed1861d6a962ade10878fd07223435d333a7089.source +0 -469
  220. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/86b7c1daafc8b60ba9982dde5bf1bcc74265709483c176e71f23ec6fc267451f.source +0 -259
  221. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/8df66d62bd41984d0970e8236882080c5085ac937d07a29779463d6a03b37c76.source +0 -97
  222. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/9962f2d1b3a85137bd40cc835725600b39f89ee02a27c06b2e0402e1eafde115.source +0 -277
  223. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a1090ea066b792cb201739f4994e37bf7c12f52e805239a1eab87f18a537f266.source +0 -171
  224. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a5241091650134d0919897e086a606a3846c8b2b54f8b4c84a8696c145ac3e9e.source +0 -425
  225. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/af19fa4c6e34d5540028be4b17600161283af3a2a2dfc61a3fdc0834436a0c14.source +0 -99
  226. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3713d78e2b2f93905ff6be8d58a8d0a2f42dea322eb67c987fda22ff6a084d5.source +0 -325
  227. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3849943c3aff102d1af815631fc843a2a52c17646a4b04696ece323e483bbec.source +0 -332
  228. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/bac735a3803a6650b7f1eb8f587e6dc5c68ea055000c3c99a11789438b78e837.source +0 -43
  229. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d32c24d61eb8b33c73e5a8fc7e4dc7ef437aed238ee4371c2b00617e6104090a.source +0 -154
  230. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d5170dc9a9879fda04fdf91d8a110d4efb6787ee38dda596d4da899883d36a72.source +0 -531
  231. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/dc59bcd7bb397be63ba2f2c3fec23f3782dcb17d2d75a9981eeced761f33eb32.source +0 -439
  232. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/eb5ff8cf66b92aec7bf6726781d8df74faa2b030c3aaac17d6b00c1fc04c9b32.source +0 -323
  233. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f10615a6fae18436e70d1201b2d44fdae3174a3aa7bb4db97dea1b3ed291d721.source +0 -433
  234. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f36d3e271668bc1260c2823542d22dbf1d0f7041ff16b1ac175bce2256e582ca.source +0 -280
  235. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f94449380af09f31ac352152007146a75cc80bae8d6411ffe57b2c56f933b0ff.source +0 -117
  236. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/fe4dfd81178fd6b648335f6405d9ede71319aebde796008e50db21f8adf44c99.source +0 -160
  237. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/ffb4e3aa548cfdadabaa44ab599f9b6a3ca6288176a2cc7a4b2fc2a12b5fb848.source +0 -101
  238. package/scripts/.mimosa/hook-status/sess_aeaecb58-8833-4b83-b93c-050467d21e03-d944d71bb3.json +0 -14
  239. package/scripts/_dbg-audit-all-pages.mjs +0 -43
  240. package/scripts/_dbg-daq-frames-e2e.mjs +0 -118
  241. package/scripts/_dbg-daq-rowstyle-verify.mjs +0 -112
  242. package/scripts/_dbg-hitl-timeout-e2e.mjs +0 -93
  243. package/scripts/_dbg-live-line-e2e.mjs +0 -279
  244. package/scripts/_dbg-minimal-omp.mjs +0 -77
  245. package/scripts/_dbg-mode-nav.mjs +0 -173
  246. package/scripts/_dbg-real-scenario-timeline.log +0 -55
  247. package/scripts/_dbg-team-repro.mjs +0 -76
  248. package/scripts/_dbg-town-agents-probe.mjs +0 -60
  249. package/scripts/_dbg-visual-acceptance.mjs +0 -63
  250. package/scripts/_townview_style.css +0 -1185
  251. package/scripts/_townview_template.html +0 -1215
  252. package/server/data/agent-node-bindings.json +0 -554
  253. package/server/data/daq-templates.json +0 -16
  254. package/server/data/daqs.json +0 -7464
  255. package/server/data/dcw-lines.json +0 -1787
  256. package/server/data/dcw-products.json +0 -1626
  257. package/server/data/dcw-recipes.json +0 -4672
  258. package/server/data/dcw-rollback.json +0 -20754
  259. package/server/data/dcw-runs.json +0 -6354
  260. package/server/data/dcw-templates.json +0 -146
  261. package/server/data/dcw-writes.json +0 -15650
  262. package/server/data/dcws.json +0 -9033
  263. package/server/data/device-twins.json +0 -133
  264. package/server/data/line-runs.json +0 -322
  265. package/server/data/scene-layouts.json +0 -277
@@ -1,2316 +1,2316 @@
1
- <script setup lang="ts">
2
- /**
3
- * 数采中心(DAQ Console)—— server 驱动数采的总控面。
4
- * 后端能力自描述(tsdb/queue/驱动族 + 管线指标)、控制器全局启停/周期、
5
- * 节点清单(状态/实时值/周期/绑定/驱动),点进 /daq/[id] 进入单节点专业控制台。
6
- */
7
- import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
8
- import { message } from 'ant-design-vue'
9
- import { useDaqStream, type DaqNodeLive } from '@/app/composables/workshop/useDaqStream'
10
- import { useDcwStream } from '@/app/composables/workshop/useDcwStream'
11
- import { useWorkshopWs } from '@/app/composables/workshop/useWorkshopWs'
12
- import { DAQ_TEMPLATES, DAQ_DRIVERS, DAQ_TEMPLATE_ICONS, daqKeyFromRef, type DaqDriverCatalogEntry, type DaqNodeView, type DaqNodeState, type DriverConfigField, type DriverTestResult as DaqDriverTestResult, type DaqTemplateDef, type DaqTemplateIcon } from '#shared/daq-protocol'
13
- import { useDeviceTwins } from '@/app/composables/workshop/useDeviceTwins'
14
- import { useOpsLog } from '@/app/composables/workshop/useOpsLog'
15
- import { useVisibleInterval } from '@/app/composables/workshop/useVisibleInterval'
16
-
17
- const { t: tt } = useI18n()
18
-
19
- definePageMeta({ layout: 'default' })
20
- useHead({ title: () => tt('titles.daq') })
21
-
22
- const daq = useDaqStream()
23
- const dcw = useDcwStream()
24
- /** WS 会话:连接层即注册 scene peer —— daq.reading 实时帧直达本页(不再等 5s 轮询) */
25
- const ws = useWorkshopWs()
26
-
27
- /** 是否存在任意运行中的产线(横幅判定) */
28
- const anyLineActive = computed(() => dcw.lines.some(l => dcw.lineStateOf(l.id).active))
29
-
30
- // ---------- 运维日志(全操作统一记录):实时事件轨渲染摘要,详情在 /logs 日志管理 ----------
31
- const opsLog = useOpsLog()
32
-
33
- function opsKindLabel(kind: string): string {
34
- const m: Record<string, string> = { write: tt('logs.kind.write'), manual: tt('logs.kind.manual'), alarm: tt('logs.kind.alarm'), line: tt('logs.kind.line'), recipe: tt('logs.kind.recipe'), rollback: tt('logs.kind.rollback'), daq: tt('logs.kind.daq'), system: tt('logs.kind.system') }
35
- return m[kind] ?? (kind || tt('logs.kind.system'))
36
- }
37
-
38
- function opsTime(at: string): string {
39
- const d = new Date(at)
40
- return Number.isFinite(d.getTime())
41
- ? `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
42
- : ''
43
- }
44
-
45
- /** 未确认报警确认(失败 toast 后端可读原因;成功后 fetchAlarms 已由 store 内部刷新) */
46
- async function ackOne(id: string): Promise<void> {
47
- try {
48
- await daq.ackAlarm(id)
49
- message.success(tt('daq.k1acked144'))
50
- }
51
- catch (err) {
52
- message.error(apiErrorMessage(err))
53
- }
54
- }
55
-
56
- let unsub: (() => void) | null = null
57
- onMounted(() => {
58
- // WS 会话建连(连接层即注册 scene peer):daq.reading 实时帧直达,不再等 5s 轮询
59
- ws.ensureConnected()
60
- unsub = daq.ensureWsFeed()
61
- // 运维日志实时事件轨(ops.log 帧;越限告警亦入轨 —— server 权威,详情在 /logs)
62
- opsLog.ensureLive()
63
- void daq.load()
64
- // 未确认报警首轮拉取(S5;WS daq.alarm 直推 + 此后随低频刷新拍轮询兜底)
65
- void daq.fetchAlarms()
66
- // 产线门控状态(无活动配方不采集;状态仅展示,控制在产线运营页)
67
- void dcw.load()
68
- // 设备注册表(绑定设备列:显示名 + 可编辑换绑;与数字孪生同源 bind REST)
69
- void deviceTwins.load()
70
- // Agent 优化记录首屏预取(面板展开即有数据;展开时仍会重拉一次保新鲜)
71
- void dcw.loadOptimizations()
72
- // meta 指标随读数帧落库节奏低频刷新(诚实可见的管线运行数据);
73
- // 产线运行态(横幅/产线列注记)同拍刷新 —— 开跑/停线后本页 ≤5s 收敛;
74
- // dcw 全量快照(95+ 线/配方/历史)是重载荷且行控状态已有 WS 帧收敛,降至 15s 兜底;
75
- // 轮询走可见性调度:后台自动降频(5s→30s / 15s→60s),回前台立即补拍,主线程不被占满
76
- useVisibleInterval(() => {
77
- void daq.load()
78
- void daq.fetchAlarms()
79
- }, 5000, { bgMs: 30000 })
80
- useVisibleInterval(() => {
81
- void dcw.load()
82
- }, 15000, { bgMs: 60000 })
83
- })
84
- onBeforeUnmount(() => {
85
- unsub?.()
86
- })
87
-
88
- // ---------- 节点筛选(产线/设备绑定/产线运行态/节点状态 + 模板/驱动/数据时间/名称搜索;全部 AND 复合) ----------
89
- const filters = reactive({ lineId: '', deviceId: '', template: '', driver: '', lineRun: '', state: '', time: '', search: '' })
90
- const hasFilters = computed(() => !!(filters.lineId || filters.deviceId || filters.template || filters.driver || filters.lineRun || filters.state || filters.time || filters.search.trim()))
91
- function clearFilters(): void {
92
- filters.lineId = ''
93
- filters.deviceId = ''
94
- filters.template = ''
95
- filters.driver = ''
96
- filters.lineRun = ''
97
- filters.state = ''
98
- filters.time = ''
99
- filters.search = ''
100
- }
101
-
102
- /** 模板筛选选项:节点中出现过的 templateRef(计数),名称走 server 模板目录降级原文 */
103
- const templateOptions = computed(() => {
104
- const used = new Map<string, number>()
105
- for (const n of daq.nodes) used.set(n.templateRef, (used.get(n.templateRef) ?? 0) + 1)
106
- return [...used.entries()].map(([ref, count]) => ({ ref, count, label: daqTemplateRefCh(ref) }))
107
- })
108
-
109
- /** 驱动筛选选项:节点中出现过的驱动(计数) */
110
- const driverOptions = computed(() => {
111
- const used = new Map<string, number>()
112
- for (const n of daq.nodes) used.set(n.driver, (used.get(n.driver) ?? 0) + 1)
113
- return [...used.entries()].map(([kind, count]) => ({
114
- kind,
115
- count,
116
- label: daq.meta.drivers.find(d => d.kind === kind)?.label ?? kind,
117
- }))
118
- })
119
-
120
- /** 数据时间语义桶:最后采样距 now 的Fresh度(与行级 stale 判定同源的诚实时间观) */
121
- function timeBucketOf(n: DaqNodeView): 'live' | 'hour' | 'day' | 'old' | 'never' {
122
- if (!n.lastAt) return 'never'
123
- const age = Date.now() - Date.parse(n.lastAt)
124
- if (age <= 5 * 60_000) return 'live'
125
- if (age <= 3_600_000) return 'hour'
126
- if (age <= 86_400_000) return 'day'
127
- return 'old'
128
- }
129
- function timeMatch(n: DaqNodeView): boolean {
130
- return !filters.time || timeBucketOf(n) === filters.time
131
- }
132
- function searchMatch(n: DaqNodeView): boolean {
133
- const q = filters.search.trim().toLowerCase()
134
- if (!q) return true
135
- return n.name.toLowerCase().includes(q) || n.id.toLowerCase().includes(q)
136
- }
137
-
138
- // ---------- Agent 优化记录(调控闭环;数采中心视角) ----------
139
- const optOpen = ref(false)
140
- /** 首次展开后保持内容挂载(手风琴动画需要);收起态内容不再重渲染 */
141
- const optMounted = ref(false)
142
- const optFilterLine = ref('')
143
- const optFilterRecipe = ref('')
144
- const optSeriesOf = ref<Record<string, { channels: Array<{ nodeId: string, nodeName: string, ch: string, unit: string, points: Array<{ at: number, value?: number, avg?: number }> }> }>>({})
145
- const optSeriesLoading = ref('')
146
-
147
- /** Recipe 选项:选定产线时只列该线配方,否则全量(与产线筛选级联) */
148
- const optRecipeOptions = computed(() =>
149
- dcw.recipes.filter(r => !optFilterLine.value || r.lineId === optFilterLine.value))
150
-
151
- function optStatusKey(s: string): string {
152
- const map: Record<string, string> = { 'open': 'daq.k1optst001', 'judged': 'daq.k1optst008', 'judged-keep': 'daq.k1optst002', 'rolled-back': 'daq.k1optst003', 'superseded': 'daq.k1optst004', 'superseded-manual': 'daq.k1optst005', 'closed-line-stop': 'daq.k1optst006', 'failed': 'daq.k1optst007' }
153
- return map[s] ?? 'daq.k1optst001'
154
- }
155
-
156
- async function toggleOptPanel(): Promise<void> {
157
- optOpen.value = !optOpen.value
158
- if (optOpen.value) {
159
- optMounted.value = true
160
- await filterOpts()
161
- }
162
- }
163
-
164
- async function filterOpts(): Promise<void> {
165
- await dcw.loadOptimizations({
166
- lineId: optFilterLine.value || undefined,
167
- recipeId: optFilterRecipe.value || undefined,
168
- })
169
- }
170
-
171
- /** 产线筛选变化:Recipe 级联重置(所选 Recipe 不在新产线内时) */
172
- function onOptLineChange(): void {
173
- if (optFilterRecipe.value && !optRecipeOptions.value.some(r => r.id === optFilterRecipe.value))
174
- optFilterRecipe.value = ''
175
- void filterOpts()
176
- }
177
-
178
- async function showOptSeries(id: string): Promise<void> {
179
- optSeriesLoading.value = id
180
- try {
181
- const s = await dcw.optimizationSeries(id)
182
- optSeriesOf.value = { ...optSeriesOf.value, [id]: s }
183
- }
184
- finally {
185
- optSeriesLoading.value = ''
186
- }
187
- }
188
-
189
- function seriesPath(points: Array<{ at: number, value?: number, avg?: number }>, w = 240, h = 36): string {
190
- const vals = points.map(p => p.value ?? p.avg ?? 0).filter(Number.isFinite)
191
- if (vals.length < 2) return ''
192
- const min = Math.min(...vals)
193
- const max = Math.max(...vals)
194
- const span = max - min || 1
195
- return vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - ((v - min) / span) * h}`).join(' ')
196
- }
197
-
198
- async function rollbackRecord(id: string): Promise<void> {
199
- await dcw.rollbackOptimization(id)
200
- await filterOpts()
201
- }
202
-
203
- /** 序列点换算的小数位(避免 sparkline 抖动) */
204
- function fmtPoint(v: number | null | undefined): string {
205
- return v == null ? '--' : String(Number(v.toFixed(2)))
206
- }
207
-
208
- /** 筛选下拉的设备选项:仅列出至少绑定了一个节点的设备(空设备筛选无意义) */
209
- const boundDevices = computed<Array<{ id: string, name: string, count: number }>>(() => {
210
- const used = new Map<string, number>()
211
- for (const n of daq.nodes) {
212
- for (const d of n.deviceIds ?? (n.deviceBindingId ? [n.deviceBindingId] : [])) {
213
- used.set(d, (used.get(d) ?? 0) + 1)
214
- }
215
- }
216
- return deviceTwins.twins
217
- .filter(t => used.has(t.id))
218
- .map(t => ({ id: t.id, name: nodeDevices.get(t.id) ?? t.name, count: used.get(t.id)! }))
219
- })
220
-
221
- /** 产线运行态筛选语义:on = 节点所属产线开跑中;off = 待机或未分配(均未采集) */
222
- function lineRunMatch(n: DaqNodeView): boolean {
223
- if (!filters.lineRun) return true
224
- const active = !!n.lineId && dcw.lineStateOf(n.lineId).active
225
- return filters.lineRun === 'on' ? active : !active
226
- }
227
-
228
- // ---------- 筛选产线的上下文横幅(运行态/产品/Recipe;未筛选到具体产线时回退全局门控提示) ----------
229
- const filteredLine = computed(() => dcw.lines.find(l => l.id === filters.lineId) ?? null)
230
- const filteredLineState = computed(() =>
231
- filteredLine.value ? dcw.lineStateOf(filteredLine.value.id) : null)
232
-
233
- // ---------- 行级展示状态(真实场景语义;筛选用,pill 呈现在 DaqNodeRow 行组件) ----------
234
- // 状态机:disabled(人为停用) → unassigned(未挂产线,永不采集) → idle(产线未开跑,采集门控)
235
- // → offline(网关暂停,或产线运行中却采不到新数据) → ok/warn/alarm(新鲜数据按量程/预警带/配方窗口派生)
236
- type RowState = DaqNodeState | 'idle' | 'disabled' | 'unassigned'
237
-
238
- /** 数据新鲜度:超过 max(4×有效周期, 12s) 无新样本视为「采不到数据」(容忍 5s REST 刷新拍与时钟偏差) */
239
- function staleOf(n: DaqNodeView): boolean {
240
- if (!n.lastAt) return true
241
- const iv = n.intervalMs ?? daq.controller.defaultIntervalMs
242
- return Date.now() - Date.parse(n.lastAt) > Math.max(iv * 4, 12_000)
243
- }
244
-
245
- function rowStateOf(n: DaqNodeView): RowState {
246
- if (!n.enabled) return 'disabled'
247
- if (!n.lineId) return 'unassigned'
248
- if (!dcw.lineStateOf(n.lineId).active) return 'idle'
249
- if (!daq.controller.running || staleOf(n)) return 'offline'
250
- return n.state
251
- }
252
-
253
- const filteredNodes = computed<DaqNodeLive[]>(() => daq.nodes.filter((n) => {
254
- if (filters.lineId && (filters.lineId === 'none' ? !!n.lineId : (n.lineId ?? '') !== filters.lineId)) return false
255
- const nodeDevIds = n.deviceIds ?? (n.deviceBindingId ? [n.deviceBindingId] : [])
256
- if (filters.deviceId && (filters.deviceId === 'none' ? nodeDevIds.length > 0 : !nodeDevIds.includes(filters.deviceId))) return false
257
- if (filters.template && n.templateRef !== filters.template) return false
258
- if (filters.driver && n.driver !== filters.driver) return false
259
- if (!lineRunMatch(n)) return false
260
- if (filters.state && rowStateOf(n) !== filters.state) return false
261
- if (!timeMatch(n)) return false
262
- if (!searchMatch(n)) return false
263
- return true
264
- }))
265
-
266
- /* ── 节点表窗口化渲染(virtual window) ────────────────────────────────────────
267
- * 为什么必须做:节点表是**全量**渲染的,而每一行都带实时读数 + sparkline,
268
- * 每次读数合批(500ms)都会点亮所有行。实测 498 个节点时:
269
- * · DOM 节点 13.5 万
270
- * · 主线程每秒 ≈3.8 万次 DOM 变更(其中 99.6% 来自这张表)
271
- * · 帧率掉到 24 FPS、92% 的帧超时、单帧最大间隔 361ms、长任务阻塞 936ms
272
- * 也就是说"数据刷新太频繁导致卡顿"的根因不是刷新频率,而是**离屏行也在被补丁**。
273
- *
274
- * 做法:按页面滚动窗口只挂载可见行 + 上下各 OVERSCAN 行,用两条占位 <tr> 撑起
275
- * 滚动高度。行的实际高度由 contain-intrinsic-size 固定为 42px(见样式),所以
276
- * 窗口计算不需要测量 DOM。
277
- *
278
- * 为什么用**页面滚动**而不是容器滚动:这张表的滚动条是页面的(.table-card 只负责
279
- * 横向),所以窗口要跟着 window.scrollY 走。
280
- */
281
- const VS_ROW_H = 42
282
- const VS_OVERSCAN = 8
283
- const nodesTableRef = ref<HTMLTableElement | null>(null)
284
- const winStart = ref(0)
285
- const winEnd = ref(60)
286
-
287
- const visibleNodes = computed<DaqNodeLive[]>(() => {
288
- const list = filteredNodes.value
289
- const s = Math.min(winStart.value, Math.max(0, list.length - 1))
290
- return list.slice(s, winEnd.value)
291
- })
292
- const padTopPx = computed(() => winStart.value * VS_ROW_H)
293
- const padBottomPx = computed(() => Math.max(0, (filteredNodes.value.length - winEnd.value) * VS_ROW_H))
294
-
295
- let winRaf = 0
296
- function updateNodeWindow(): void {
297
- const el = nodesTableRef.value
298
- if (!el) return
299
- const total = filteredNodes.value.length
300
- const vh = window.innerHeight || 800
301
- // 表格顶部相对文档的坐标(表头 40px 也算进"还没到第一行")
302
- const tableTop = el.getBoundingClientRect().top + window.scrollY + 40
303
- const firstVisible = Math.floor((window.scrollY - tableTop) / VS_ROW_H)
304
- const count = Math.ceil(vh / VS_ROW_H) + VS_OVERSCAN * 2
305
- const s = Math.max(0, Math.min(firstVisible - VS_OVERSCAN, Math.max(0, total - 1)))
306
- const e = Math.max(s, Math.min(total, s + count))
307
- if (s !== winStart.value) winStart.value = s
308
- if (e !== winEnd.value) winEnd.value = e
309
- }
310
- function scheduleNodeWindow(): void {
311
- if (winRaf) return
312
- winRaf = requestAnimationFrame(() => {
313
- winRaf = 0
314
- updateNodeWindow()
315
- })
316
- }
317
-
318
- onMounted(() => {
319
- updateNodeWindow()
320
- window.addEventListener('scroll', scheduleNodeWindow, { passive: true })
321
- window.addEventListener('resize', scheduleNodeWindow, { passive: true })
322
- })
323
- onBeforeUnmount(() => {
324
- window.removeEventListener('scroll', scheduleNodeWindow)
325
- window.removeEventListener('resize', scheduleNodeWindow)
326
- if (winRaf) cancelAnimationFrame(winRaf)
327
- })
328
- // 筛选/搜索改变行数后窗口必须重算(否则会停在旧的切片上,看起来"表空了")
329
- watch(() => filteredNodes.value.length, () => {
330
- winStart.value = 0
331
- winEnd.value = Math.min(filteredNodes.value.length, winEnd.value || 60)
332
- scheduleNodeWindow()
333
- })
334
-
335
- // 设备名映射(device-twins 注册表;绑定筛选下拉展示用)
336
- const deviceTwins = useDeviceTwins()
337
- const nodeDevices = new Map<string, string>()
338
- watch(() => deviceTwins.twins, (list) => {
339
- for (const t of list) nodeDevices.set(t.id, t.name)
340
- }, { immediate: true, deep: true })
341
-
342
- // ---------- 添加节点向导(mock / 真实场景 + 动态参数表单 + 测试连接) ----------
343
- const addOpen = ref(false)
344
- const addScenario = ref<'mock' | 'real'>('mock')
345
- const addTemplate = ref(DAQ_TEMPLATES[0]?.key ?? 'temp-tc')
346
- const addDriver = ref('modbus-tcp')
347
- const addName = ref('')
348
- const addInterval = ref<number | null>(null)
349
- const addCfg = ref<Record<string, string | number>>({})
350
- const addTransform = reactive({ kind: 'none' as 'none' | 'linear', scale: 1, offset: 0 })
351
- const addTesting = ref(false)
352
- const addTest = ref<DaqDriverTestResult | null>(null)
353
- const addSaving = ref(false)
354
- const addError = ref('')
355
-
356
- // 驱动目录:server 权威(内置 + 协议插件自描述;REST 未返回时回落静态目录)
357
- const driverCatalog = computed<DaqDriverCatalogEntry[]>(() =>
358
- daq.meta.drivers.length ? daq.meta.drivers : DAQ_DRIVERS.map(d => ({ ...d })))
359
- const addDriverMeta = computed(() => driverCatalog.value.find(d => d.kind === addDriver.value))
360
- const addFields = computed<DriverConfigField[]>(() => addDriverMeta.value?.configFields ?? [])
361
- const driverReady = (kind: string): boolean =>
362
- daq.meta.drivers.find(d => d.kind === kind)?.status !== 'planned' && (daq.meta.driverAvailable?.[kind] !== false)
363
-
364
- watch(addDriver, () => {
365
- // 切协议:表单重置为 schema 缺省值
366
- const cfg: Record<string, string | number> = {}
367
- for (const f of addFields.value) {
368
- if (f.default !== undefined) cfg[f.key] = f.default as string | number
369
- }
370
- addCfg.value = cfg
371
- addTest.value = null
372
- }, { immediate: true })
373
-
374
- async function doTestConnection(): Promise<void> {
375
- addTesting.value = true
376
- addTest.value = null
377
- try {
378
- addTest.value = await daq.testDriver(addDriver.value, addCfg.value)
379
- }
380
- catch (err) {
381
- addTest.value = { ok: false, message: apiErrorMessage(err) }
382
- }
383
- finally {
384
- addTesting.value = false
385
- }
386
- }
387
-
388
- async function doAddNode(): Promise<void> {
389
- addSaving.value = true
390
- addError.value = ''
391
- try {
392
- const transform = addTransform.kind === 'linear'
393
- ? { kind: 'linear' as const, scale: Number(addTransform.scale), offset: Number(addTransform.offset) }
394
- : undefined
395
- if (addScenario.value === 'mock') {
396
- await daq.createFromTemplate(`daq-${addTemplate.value}`, {
397
- name: addName.value ? { name: addName.value }.name : undefined,
398
- transform,
399
- })
400
- }
401
- else {
402
- // 校验必填
403
- for (const f of addFields.value) {
404
- if (f.required && !addCfg.value[f.key] && addCfg.value[f.key] !== 0) {
405
- throw new Error(tt('daq.kyuadl0125', { p0: f.label }))
406
- }
407
- }
408
- const tpl = daq.templates.find(t => t.key === addTemplate.value)
409
- await daq.createFromTemplate(`daq-${addTemplate.value}`, {
410
- name: addName.value || undefined,
411
- driver: addDriver.value as never,
412
- driverConfig: { ...addCfg.value },
413
- transform,
414
- intervalMs: addInterval.value,
415
- unit: tpl?.unit,
416
- min: tpl?.min,
417
- max: tpl?.max,
418
- decimals: tpl?.decimals,
419
- } as never)
420
- }
421
- addOpen.value = false
422
- addName.value = ''
423
- addTest.value = null
424
- }
425
- catch (err) {
426
- addError.value = apiErrorMessage(err)
427
- }
428
- finally {
429
- addSaving.value = false
430
- }
431
- }
432
-
433
- /** 模板通道语义(server 目录为唯一事实源;模板已删除 → 显示 templateRef 原文降级) */
434
- function daqTemplateRefCh(templateRef: string): string {
435
- const tpl = daq.templates.find(t => t.key === daqKeyFromRef(templateRef))
436
- return tpl ? `${tpl.name} · ${tpl.ch}` : templateRef || '-'
437
- }
438
-
439
- // ---------- 自定义信号模板管理(server 权威 CRUD;内置只读可复制) ----------
440
- const tplOpen = ref(false)
441
- const tplEditing = ref<string | null>(null)
442
- const tplSaving = ref(false)
443
- const tplError = ref('')
444
- const confirmingDel = ref('')
445
- const tplForm = reactive({
446
- name: '', ch: '', code: '', unit: '',
447
- min: 0, max: 100, base: 50, amp: 2,
448
- decimals: 2, icon: 'thermo' as DaqTemplateIcon,
449
- })
450
- const ICON_LABEL: Record<DaqTemplateIcon, string> = {
451
- thermo: tt('daq.k422b8079'), pressure: tt('daq.k3x6ff080'), tension: tt('daq.k3z9xc081'), encoder: tt('daq.k3subk4082'), camera: tt('daq.k47atw083'), gateway: tt('daq.kz7yhbj084'),
452
- }
453
-
454
- const customTpls = computed<DaqTemplateDef[]>(() => daq.templates.filter(t => !t.builtin))
455
- const builtinTpls = computed<DaqTemplateDef[]>(() => daq.templates.filter(t => t.builtin))
456
-
457
- function fillTplForm(t: DaqTemplateDef, asCopy = false): void {
458
- tplForm.name = asCopy ? tt('daq.k2hbo3c126', { p0: t.name }) : t.name
459
- tplForm.ch = t.ch
460
- tplForm.code = t.code
461
- tplForm.unit = t.unit
462
- tplForm.min = t.min
463
- tplForm.max = t.max
464
- tplForm.base = t.base
465
- tplForm.amp = t.amp
466
- tplForm.decimals = t.decimals
467
- tplForm.icon = t.icon
468
- }
469
-
470
- function resetTplForm(): void {
471
- tplEditing.value = null
472
- tplError.value = ''
473
- confirmingDel.value = ''
474
- fillTplForm({ key: '', name: '', code: '', ch: '', unit: '', base: 50, amp: 2, min: 0, max: 100, decimals: 2, icon: 'thermo' })
475
- }
476
-
477
- function editTpl(t: DaqTemplateDef): void {
478
- tplEditing.value = t.key
479
- tplError.value = ''
480
- fillTplForm(t)
481
- }
482
-
483
- function copyTpl(t: DaqTemplateDef): void {
484
- tplEditing.value = null
485
- tplError.value = ''
486
- fillTplForm(t, true)
487
- }
488
-
489
- async function saveTpl(): Promise<void> {
490
- tplSaving.value = true
491
- tplError.value = ''
492
- try {
493
- const num = (v: number | string): number | undefined => {
494
- const n = Number(v)
495
- return v === '' || v == null || !Number.isFinite(n) ? undefined : n
496
- }
497
- const input = {
498
- name: tplForm.name.trim(),
499
- ch: tplForm.ch.trim() || undefined,
500
- code: tplForm.code.trim() || undefined,
501
- unit: tplForm.unit.trim(),
502
- min: num(tplForm.min)!,
503
- max: num(tplForm.max)!,
504
- base: num(tplForm.base),
505
- amp: num(tplForm.amp),
506
- decimals: num(tplForm.decimals),
507
- icon: tplForm.icon,
508
- }
509
- if (tplEditing.value) await daq.updateTemplate(tplEditing.value, input)
510
- else await daq.createTemplate(input)
511
- resetTplForm()
512
- }
513
- catch (err) {
514
- tplError.value = apiErrorMessage(err)
515
- }
516
- finally {
517
- tplSaving.value = false
518
- }
519
- }
520
-
521
- /** 两段式删除确认(避免误删;空目录风格与节点表一致) */
522
- async function askDelTpl(t: DaqTemplateDef): Promise<void> {
523
- if (confirmingDel.value !== t.key) {
524
- confirmingDel.value = t.key
525
- return
526
- }
527
- try {
528
- await daq.removeTemplate(t.key)
529
- }
530
- catch (err) {
531
- tplError.value = apiErrorMessage(err)
532
- }
533
- finally {
534
- confirmingDel.value = ''
535
- }
536
- }
537
-
538
- const reconnecting = ref(false)
539
- async function doReconnect(): Promise<void> {
540
- reconnecting.value = true
541
- try {
542
- await daq.reconnectInfra()
543
- }
544
- catch { /* 横幅仍在,30s 后台也会自动重试 */ }
545
- finally {
546
- reconnecting.value = false
547
- }
548
- }
549
- </script>
550
-
551
- <template>
552
- <div class="page">
553
- <div class="aw-page-head">
554
- <div>
555
- <p class="aw-kicker">
556
- AGENTWORKSHOP / DAQ CONSOLE
557
- </p>
558
- <h1>{{ $t('daq.k1emg364011') }}</h1>
559
- <p class="sub">
560
- {{ $t('daq.k1wmg2bz012') }}
561
- </p>
562
- </div>
563
- <div class="badges mono">
564
- <span
565
- class="badge"
566
- :title="$t('daq.k1tsdbtip133')"
567
- >TSDB · {{ daq.meta.tsdb }}</span>
568
- <span
569
- class="badge"
570
- :title="$t('daq.k1queuet134')"
571
- >QUEUE · {{ daq.meta.queue }}</span>
572
- </div>
573
- </div>
574
-
575
- <!-- 产线门控横幅:筛选到具体产线 → 展示该产线运行态/产品/Recipe(待机可直达产线管理);
576
- 未筛选具体产线 → 全局门控提示 -->
577
- <div
578
- v-if="daq.loaded && filteredLine && filteredLineState"
579
- class="infra-banner"
580
- :class="{ good: filteredLineState.active }"
581
- >
582
- <span :class="filteredLineState.active ? 'i-tabler-circle-check' : 'i-tabler-info-circle'" />
583
- <span class="txt">
584
- <template v-if="filteredLineState.active">
585
- {{ $t('daq.k3ktxbr085') }}{{ filteredLine.name }}」{{ $t('daq.k15lvw0o105') }}<b>{{ filteredLineState.productName }}</b> · Recipe:<b>{{ filteredLineState.recipeName }}</b>
586
- <small class="mono">{{ $t('daq.k400lb086') }} {{ filteredLineState.runId?.slice(0, 8) }} · {{ $t('daq.k3zkt2106') }} {{ filteredLineState.startedAt?.slice(11, 19) }} {{ $t('daq.k69vag8108') }} {{ filteredLineState.taggedSamples }} {{ $t('daq.k4118o092') }}</small>
587
- </template>
588
- <template v-else>
589
- {{ $t('daq.k3ktxbr085') }}{{ filteredLine.name }}」{{ $t('daq.k12uge2o107') }}</template>
590
- </span>
591
- <NuxtLink
592
- class="pill-btn"
593
- :to="`/dcw/${filteredLine.id}`"
594
- >
595
- {{ filteredLineState.active ? $t('daq.k1ukoy0v093') : $t('daq.k1cg78i8109') }}
596
- </NuxtLink>
597
- </div>
598
- <div
599
- v-else-if="daq.loaded && !anyLineActive"
600
- class="infra-banner"
601
- >
602
- <span class="i-tabler-info-circle" />
603
- <span class="txt">{{ $t('daq.k19onw5w013') }}<NuxtLink to="/dcw">{{ $t('daq.k1b2tk5c014') }}</NuxtLink>{{ $t('daq.k1u9wnlg015') }}</span>
604
- </div>
605
-
606
- <!-- 基础设施降级横幅(MQTT/Timescale 不可达:在线采集停用 + 一键重连) -->
607
- <div
608
- v-if="daq.meta.infra?.degraded"
609
- class="infra-banner"
610
- >
611
- <span class="i-tabler-alert-triangle" />
612
- <span class="txt">{{ daq.meta.infra.warning }}</span>
613
- <button
614
- class="pill-btn"
615
- :disabled="reconnecting"
616
- @click="doReconnect"
617
- >
618
- {{ reconnecting ? $t('daq.k1ld43ur094') : $t('daq.kzg9805110') }}
619
- </button>
620
- </div>
621
-
622
- <!-- 未确认报警条(S5):边缘触发产生 / 轮询收敛,确认后立即消失 -->
623
- <div
624
- v-if="daq.alarms.length"
625
- class="alarm-bar"
626
- >
627
- <span class="bar-label">
628
- <span class="i-tabler-bell-ringing bell" />
629
- {{ $t('daq.k1alarm141') }}
630
- <b class="mono">{{ daq.alarms.length }}</b>
631
- </span>
632
- <div class="alarm-scroll">
633
- <span
634
- v-for="a in daq.alarms"
635
- :key="a.id"
636
- class="alarm-item"
637
- :title="$t('daq.k1esc143', { p0: a.escalation })"
638
- >
639
- <b>{{ a.nodeName }}</b>
640
- <span class="mono dim">{{ a.metric }}</span>
641
- <span class="mono">{{ a.value }} {{ a.rule === 'lt-min' ? '<' : '>' }} {{ a.threshold }}</span>
642
- <small
643
- v-if="a.escalation > 0"
644
- class="esc mono"
645
- >+{{ a.escalation }}</small>
646
- <button
647
- class="mini-btn ack"
648
- @click="ackOne(a.id)"
649
- >
650
- {{ $t('daq.k1ack142') }}
651
- </button>
652
- </span>
653
- </div>
654
- </div>
655
-
656
- <!-- 控制器总控条:第一行 控制/指标/操作,第二行 产线状态带 -->
657
- <section class="aw-tile ctrl-card">
658
- <div class="ctrl-row">
659
- <div class="ctrl-left">
660
- <button
661
- class="aw-pill"
662
- :class="{ running: daq.controller.running }"
663
- :title="$t('daq.k1snap145')"
664
- @click="daq.controllerAction(daq.controller.running ? 'pause' : 'resume')"
665
- >
666
- <span :class="daq.controller.running ? 'i-tabler-player-pause' : 'i-tabler-player-play'" />
667
- {{ daq.controller.running ? $t('daq.kpui00095') : $t('daq.knxpdpt111') }}
668
- </button>
669
- <label class="cycle mono">
670
- {{ $t('daq.k1ifahxj016') }}
671
- <input
672
- v-model.number="daq.controller.defaultIntervalMs"
673
- type="number"
674
- min="200"
675
- max="60000"
676
- step="100"
677
- @change="daq.controllerAction('config', daq.controller.defaultIntervalMs)"
678
- >ms
679
- </label>
680
- <label
681
- class="cycle mono"
682
- :title="$t('daq.k1e8lcoo001')"
683
- >
684
- {{ $t('daq.k1if98n0017') }}
685
- <input
686
- v-model.number="daq.controller.defaultPublishIntervalMs"
687
- type="number"
688
- min="0"
689
- max="60000"
690
- step="100"
691
- @change="daq.controllerAction('config', daq.controller.defaultIntervalMs, daq.controller.defaultPublishIntervalMs)"
692
- >ms
693
- </label>
694
- </div>
695
- <div class="ctrl-right">
696
- <span class="ctrl-metrics mono">
697
- <span>{{ $t('daq.k45uio067') }} {{ daq.controller.nodesOnline }}/{{ daq.controller.nodesTotal }}</span>
698
- <span class="sep">·</span>
699
- <span :title="$t('daq.k1yztsu4002')">{{ $t('daq.k3xagp087') }} {{ daq.meta.produced }}</span>
700
- <span class="sep">·</span>
701
- <span :title="$t('daq.ko09iha003')">{{ $t('daq.k427eu088') }} {{ daq.meta.consumed }}</span>
702
- <span class="sep">·</span>
703
- <span
704
- :class="{ warn: (daq.meta.dropped ?? 0) > 0 }"
705
- :title="$t('daq.k1droptip135')"
706
- >{{ $t('daq.k3w8go089') }} {{ daq.meta.dropped }}</span>
707
- <span class="sep">·</span>
708
- <span :title="$t('daq.kj1jbmw004')">{{ $t('daq.k3wusd090') }} {{ daq.meta.samplesStored }}</span>
709
- </span>
710
- <button
711
- class="aw-pill outline add-btn"
712
- @click="tplOpen = true; resetTplForm()"
713
- >
714
- <span class="i-tabler-adjustments-horizontal" />
715
- {{ $t('daq.k1f5cv0s018') }}
716
- </button>
717
- <button
718
- class="aw-pill add-btn"
719
- @click="addOpen = true"
720
- >
721
- <span class="i-tabler-plus" />
722
- {{ $t('daq.k1fn0ukb019') }}
723
- </button>
724
- </div>
725
- </div>
726
- <!-- 产线状态带:单行横向滚动,每产线一枚 pill(空心点=待机 / 实心呼吸点=运行中) -->
727
- <div class="line-strip">
728
- <span class="strip-label">
729
- <span class="i-tabler-route" />
730
- {{ $t('daq.k1b2o3b6020') }}<small class="mono">{{ dcw.lines.length }}</small>
731
- </span>
732
- <div class="strip-scroll">
733
- <NuxtLink
734
- v-for="l in dcw.lines"
735
- :key="l.id"
736
- class="line-pill"
737
- :class="{ on: dcw.lineStateOf(l.id).active }"
738
- :style="{ '--lc': l.color }"
739
- :to="`/dcw/${l.id}`"
740
- :title="dcw.lineStateOf(l.id).active
741
- ? $t('daq.k3v957u120', { p0: l.name, p1: dcw.lineStateOf(l.id).productName, p2: dcw.lineStateOf(l.id).recipeName })
742
- : $t('daq.k1x7jnru121', { p0: l.name })"
743
- >
744
- <span class="lp-dot" />
745
- <b>{{ l.name }}</b>
746
- <small
747
- v-if="dcw.lineStateOf(l.id).active"
748
- class="lp-run"
749
- >{{ dcw.lineStateOf(l.id).recipeName ?? $t('daq.k3vp67i096') }}</small>
750
- <small
751
- v-else
752
- class="lp-idle"
753
- >{{ $t('daq.k3zgkk021') }}</small>
754
- </NuxtLink>
755
- <NuxtLink
756
- v-if="dcw.lines.length === 0"
757
- class="line-pill"
758
- to="/dcw"
759
- >
760
- <span class="lp-dot" />
761
- <b>{{ $t('daq.k1el12b1022') }}</b>
762
- <small class="lp-idle">{{ $t('daq.k8jxxe8023') }}</small>
763
- </NuxtLink>
764
- </div>
765
- </div>
766
- </section>
767
-
768
- <!-- 添加节点向导:mock 模拟 / 真实设备(协议参数 + 测试连接) -->
769
- <div
770
- v-if="addOpen"
771
- class="modal-mask"
772
- @click.self="addOpen = false"
773
- >
774
- <div class="modal">
775
- <h3 class="m-title">
776
- {{ $t('daq.k19rioqa024') }}
777
- </h3>
778
-
779
- <div class="seg-row">
780
- <button
781
- class="seg"
782
- :class="{ on: addScenario === 'mock' }"
783
- @click="addScenario = 'mock'"
784
- >
785
- {{ $t('daq.k1p5c1un025') }}
786
- </button>
787
- <button
788
- class="seg"
789
- :class="{ on: addScenario === 'real' }"
790
- @click="addScenario = 'real'"
791
- >
792
- {{ $t('daq.k1cbcp3o026') }}
793
- </button>
794
- </div>
795
-
796
- <div class="f-grid">
797
- <label class="f">
798
- <span>{{ $t('daq.k1fsgerc027') }}</span>
799
- <select
800
- v-model="addTemplate"
801
- class="inp"
802
- >
803
- <option
804
- v-for="t in daq.templates"
805
- :key="t.key"
806
- :value="t.key"
807
- >
808
- {{ t.name }} · {{ t.ch }}({{ t.min }}~{{ t.max }} {{ t.unit }}){{ t.builtin ? '' : $t('daq.kr45rk9097') }}
809
- </option>
810
- </select>
811
- </label>
812
- <label class="f">
813
- <span>{{ $t('daq.k1ce2k1y028') }}</span>
814
- <input
815
- v-model="addName"
816
- class="inp"
817
- :placeholder="$t('daq.kgpxzy3005')"
818
- >
819
- </label>
820
- <label
821
- v-if="addScenario === 'real'"
822
- class="f"
823
- >
824
- <span>{{ $t('daq.k13mfsfc029') }}</span>
825
- <input
826
- v-model.number="addInterval"
827
- type="number"
828
- min="1000"
829
- max="60000"
830
- step="500"
831
- class="inp"
832
- :placeholder="$t('daq.k1eg1000a136')"
833
- >
834
- </label>
835
- </div>
836
-
837
- <!-- 真实场景:协议选择 + 动态参数表单 + 测试连接 -->
838
- <template v-if="addScenario === 'real'">
839
- <div class="f-grid">
840
- <label class="f">
841
- <span>{{ $t('daq.k1kt87rx030') }}</span>
842
- <select
843
- v-model="addDriver"
844
- class="inp"
845
- >
846
- <option
847
- v-for="d in driverCatalog.filter(x => x.status !== 'planned')"
848
- :key="d.kind"
849
- :value="d.kind"
850
- >
851
- {{ d.label }}{{ d.plugin ? ' ⌁' : '' }}{{ driverReady(d.kind) ? '' : $t('daq.kjbqphp098') }}
852
- </option>
853
- </select>
854
- </label>
855
- </div>
856
-
857
- <div
858
- v-if="addFields.length"
859
- class="f-grid driver-form"
860
- >
861
- <label
862
- v-for="f in addFields"
863
- :key="f.key"
864
- class="f"
865
- >
866
- <span>{{ f.label }}<em v-if="f.required">*</em></span>
867
- <select
868
- v-if="f.type === 'select'"
869
- v-model="addCfg[f.key]"
870
- class="inp"
871
- >
872
- <option
873
- v-for="o in f.options"
874
- :key="o.value"
875
- :value="o.value"
876
- >
877
- {{ o.label }}
878
- </option>
879
- </select>
880
- <input
881
- v-else
882
- v-model="addCfg[f.key]"
883
- :type="f.type === 'number' ? 'number' : 'text'"
884
- :placeholder="f.placeholder"
885
- class="inp"
886
- >
887
- <small
888
- v-if="f.hint"
889
- class="hint"
890
- >{{ f.hint }}</small>
891
- </label>
892
- </div>
893
-
894
- <div class="test-row">
895
- <button
896
- class="pill-btn"
897
- :disabled="addTesting"
898
- @click="doTestConnection"
899
- >
900
- {{ addTesting ? $t('daq.k1fsh720099') : $t('daq.k1fstglk112') }}
901
- </button>
902
- <span
903
- v-if="addTest"
904
- class="test-result"
905
- :class="addTest.ok ? 'ok' : 'bad'"
906
- >{{ addTest.ok ? '✓' : '✗' }} {{ addTest.message }}<template v-if="addTest.latencyMs != null">({{ addTest.latencyMs }}ms)</template></span>
907
- </div>
908
- </template>
909
-
910
- <p
911
- v-if="addError"
912
- class="m-err"
913
- >
914
- {{ addError }}
915
- </p>
916
-
917
- <div class="m-actions">
918
- <button
919
- class="aw-pill outline"
920
- @click="addOpen = false"
921
- >
922
- {{ $t('daq.k3xdnn031') }}
923
- </button>
924
- <button
925
- class="aw-pill"
926
- :disabled="addSaving || (addScenario === 'real' && addTest != null && !addTest.ok)"
927
- :title="addScenario === 'real' && !(addTest && addTest.ok) ? $t('daq.k1needtst137') : ''"
928
- @click="doAddNode"
929
- >
930
- {{ addSaving ? $t('daq.k1bg4759100') : (addScenario === 'real' ? $t('daq.k7pxjxo113') : $t('daq.k1bge46t118')) }}
931
- </button>
932
- </div>
933
- </div>
934
- </div>
935
-
936
- <!-- 模板管理:自定义模板增删改 + 内置模板复制 -->
937
- <div
938
- v-if="tplOpen"
939
- class="modal-mask"
940
- @click.self="tplOpen = false"
941
- >
942
- <div class="modal">
943
- <h3 class="m-title">
944
- {{ $t('daq.k11mv96s032') }}
945
- </h3>
946
-
947
- <p class="sec-label">
948
- {{ $t('daq.ktji4ky033') }}
949
- </p>
950
- <table class="tpl-table">
951
- <tbody>
952
- <tr
953
- v-for="t in customTpls"
954
- :key="t.key"
955
- >
956
- <td>
957
- <b>{{ t.name }}</b>
958
- <small class="mono dim">{{ t.code }}</small>
959
- </td>
960
- <td class="mono range">
961
- {{ t.min }}~{{ t.max }} {{ t.unit }} · {{ t.decimals }} {{ $t('daq.k48oi091') }}
962
- </td>
963
- <td class="right actions">
964
- <button
965
- class="mini-btn"
966
- @click="editTpl(t)"
967
- >
968
- {{ $t('daq.k45eb0034') }}
969
- </button>
970
- <button
971
- class="mini-btn danger"
972
- @click="askDelTpl(t)"
973
- >
974
- {{ confirmingDel === t.key ? $t('daq.k1hhheu3101') : $t('daq.k3xakp114') }}
975
- </button>
976
- </td>
977
- </tr>
978
- <tr v-if="!customTpls.length">
979
- <td
980
- colspan="3"
981
- class="empty"
982
- >
983
- {{ $t('daq.kztvzbo035') }}
984
- </td>
985
- </tr>
986
- </tbody>
987
- </table>
988
-
989
- <p class="sec-label">
990
- {{ tplEditing ? $t('daq.k1iiph0s102') : $t('daq.k1efixrj115') }}
991
- </p>
992
- <div class="f-grid">
993
- <label class="f">
994
- <span>{{ $t('daq.k3xhia036') }}<em>*</em></span>
995
- <input
996
- v-model="tplForm.name"
997
- class="inp"
998
- :placeholder="$t('daq.k5opd2t006')"
999
- >
1000
- </label>
1001
- <label class="f">
1002
- <span>{{ $t('daq.k3x4ef037') }}<em>*</em></span>
1003
- <input
1004
- v-model="tplForm.unit"
1005
- class="inp"
1006
- placeholder="如 %RH"
1007
- >
1008
- </label>
1009
- <label class="f">
1010
- <span>{{ $t('daq.k1l477m0038') }}</span>
1011
- <input
1012
- v-model="tplForm.ch"
1013
- class="inp"
1014
- :placeholder="$t('daq.kk8o4tl007')"
1015
- >
1016
- </label>
1017
- <label class="f">
1018
- <span>{{ $t('daq.k1ayxrqb039') }}</span>
1019
- <input
1020
- v-model="tplForm.code"
1021
- class="inp"
1022
- :placeholder="$t('daq.kzn3l7l008')"
1023
- >
1024
- </label>
1025
- <label class="f">
1026
- <span>{{ $t('daq.k1l9jv5m040') }}<em>*</em></span>
1027
- <input
1028
- v-model.number="tplForm.min"
1029
- type="number"
1030
- class="inp"
1031
- >
1032
- </label>
1033
- <label class="f">
1034
- <span>{{ $t('daq.k1l9jv4p041') }}<em>*</em></span>
1035
- <input
1036
- v-model.number="tplForm.max"
1037
- type="number"
1038
- class="inp"
1039
- >
1040
- </label>
1041
- <label class="f">
1042
- <span>{{ $t('daq.k3mxmcx042') }}</span>
1043
- <input
1044
- v-model.number="tplForm.decimals"
1045
- type="number"
1046
- min="0"
1047
- max="4"
1048
- class="inp"
1049
- >
1050
- </label>
1051
- <label class="f">
1052
- <span>{{ $t('daq.k3xx56043') }}</span>
1053
- <select
1054
- v-model="tplForm.icon"
1055
- class="inp"
1056
- >
1057
- <option
1058
- v-for="ic in DAQ_TEMPLATE_ICONS"
1059
- :key="ic"
1060
- :value="ic"
1061
- >
1062
- {{ ICON_LABEL[ic] }}
1063
- </option>
1064
- </select>
1065
- </label>
1066
- <label class="f">
1067
- <span>{{ $t('daq.k1f4eknv044') }}</span>
1068
- <input
1069
- v-model.number="tplForm.base"
1070
- type="number"
1071
- class="inp"
1072
- >
1073
- <small class="hint">{{ $t('daq.kytd15s045') }}</small>
1074
- </label>
1075
- <label class="f">
1076
- <span>{{ $t('daq.k1f4ifpo046') }}</span>
1077
- <input
1078
- v-model.number="tplForm.amp"
1079
- type="number"
1080
- class="inp"
1081
- >
1082
- <small class="hint">{{ $t('daq.ksp2aji047') }}</small>
1083
- </label>
1084
- </div>
1085
- <p
1086
- v-if="tplError"
1087
- class="m-err"
1088
- >
1089
- {{ tplError }}
1090
- </p>
1091
- <div class="m-actions">
1092
- <button
1093
- class="aw-pill outline"
1094
- @click="resetTplForm"
1095
- >
1096
- {{ $t('daq.k48p40048') }}
1097
- </button>
1098
- <button
1099
- class="pill-btn"
1100
- :disabled="tplSaving"
1101
- @click="saveTpl"
1102
- >
1103
- {{ tplSaving ? $t('daq.k1b38d59103') : (tplEditing ? $t('daq.k1b39281116') : $t('daq.k1b3dtga119')) }}
1104
- </button>
1105
- </div>
1106
-
1107
- <p class="sec-label">
1108
- {{ $t('daq.k1w8s84s049') }}
1109
- </p>
1110
- <table class="tpl-table">
1111
- <tbody>
1112
- <tr
1113
- v-for="t in builtinTpls"
1114
- :key="t.key"
1115
- >
1116
- <td>
1117
- <b>{{ t.name }}</b>
1118
- <small class="mono dim">{{ t.code }}</small>
1119
- </td>
1120
- <td class="mono range">
1121
- {{ t.min }}~{{ t.max }} {{ t.unit }} · {{ t.decimals }} {{ $t('daq.k48oi091') }}
1122
- </td>
1123
- <td class="right actions">
1124
- <button
1125
- class="mini-btn"
1126
- @click="copyTpl(t)"
1127
- >
1128
- {{ $t('daq.k3y694050') }}
1129
- </button>
1130
- </td>
1131
- </tr>
1132
- </tbody>
1133
- </table>
1134
- </div>
1135
- </div>
1136
-
1137
- <!-- Agent 优化记录(调控闭环:设定历史 / 判定 / 窗口数采;序列为客户端渲染) -->
1138
- <section class="aw-tile opt-card">
1139
- <button
1140
- class="opt-head"
1141
- @click="toggleOptPanel"
1142
- >
1143
- <span class="i-tabler-flask" />
1144
- <b>{{ $t('daq.k1optt001') }}</b>
1145
- <span class="opt-count mono">{{ dcw.optimizations.length }}</span>
1146
- <span
1147
- class="opt-chevron"
1148
- :class="{ open: optOpen }"
1149
- />
1150
- </button>
1151
- <div
1152
- class="opt-body"
1153
- :class="{ open: optOpen }"
1154
- >
1155
- <template v-if="optMounted">
1156
- <div class="opt-filters">
1157
- <select
1158
- v-model="optFilterLine"
1159
- class="inp-sel"
1160
- @change="onOptLineChange"
1161
- >
1162
- <option value="">
1163
- {{ $t('daq.k1optf001') }}
1164
- </option>
1165
- <option
1166
- v-for="l in dcw.lines"
1167
- :key="l.id"
1168
- :value="l.id"
1169
- >
1170
- {{ l.name }}
1171
- </option>
1172
- </select>
1173
- <select
1174
- v-model="optFilterRecipe"
1175
- class="inp-sel"
1176
- @change="filterOpts"
1177
- >
1178
- <option value="">
1179
- {{ $t('daq.k1optf004') }}
1180
- </option>
1181
- <option
1182
- v-for="r in optRecipeOptions"
1183
- :key="r.id"
1184
- :value="r.id"
1185
- >
1186
- {{ r.name }}
1187
- </option>
1188
- </select>
1189
- <button
1190
- class="mini-act"
1191
- @click="filterOpts"
1192
- >
1193
- {{ $t('daq.k1optf002') }}
1194
- </button>
1195
- </div>
1196
- <p
1197
- v-if="dcw.optimizations.length === 0"
1198
- class="opt-empty"
1199
- >
1200
- {{ $t('daq.k1opte001') }}
1201
- </p>
1202
- <ul class="opt-list">
1203
- <li
1204
- v-for="r in dcw.optimizations.slice(0, 30)"
1205
- :key="r.id"
1206
- class="opt-row"
1207
- :class="r.status"
1208
- >
1209
- <div class="opt-line1">
1210
- <span
1211
- class="opt-status"
1212
- :class="r.status"
1213
- >{{ $t(optStatusKey(r.status)) }}</span>
1214
- <b class="opt-node">{{ r.nodeName }}</b>
1215
- <span class="mono opt-params">{{ r.params[0]?.from ?? '?' }} → {{ r.params[0]?.to }}</span>
1216
- <span class="mono opt-time">{{ r.setAt.slice(5, 16).replace('T', ' ') }}</span>
1217
- <button
1218
- v-if="!optSeriesOf[r.id]"
1219
- class="mini-act"
1220
- :disabled="optSeriesLoading === r.id"
1221
- @click="showOptSeries(r.id)"
1222
- >
1223
- {{ $t('daq.k1opta001') }}
1224
- </button>
1225
- <button
1226
- v-if="r.status === 'open'"
1227
- class="mini-act danger"
1228
- @click="rollbackRecord(r.id)"
1229
- >
1230
- {{ $t('daq.k1opta002') }}
1231
- </button>
1232
- </div>
1233
- <div class="opt-line2 mono">
1234
- <span
1235
- v-if="r.judge"
1236
- class="opt-judge"
1237
- :class="r.judge.verdict"
1238
- >{{ r.judge.verdict }}({{ r.judge.by }}): {{ r.judge.reason.slice(0, 70) }}</span>
1239
- <span
1240
- v-else-if="r.status === 'open'"
1241
- class="opt-open-hint"
1242
- >{{ $t('daq.k1opto001') }}</span>
1243
- <span
1244
- v-if="r.aggPending"
1245
- class="opt-agg"
1246
- >{{ $t('daq.k1optw001') }}</span>
1247
- <span
1248
- v-else-if="r.windowAgg && r.windowAgg.channels.length"
1249
- class="opt-agg"
1250
- >
1251
- <template
1252
- v-for="c in r.windowAgg.channels"
1253
- :key="c.daqNodeId"
1254
- >{{ c.ch }} {{ fmtPoint(c.min) }}~{{ fmtPoint(c.max) }}({{ $t('daq.k1optw002') }}{{ c.breaches < 0 ? '-' : c.breaches }}) </template>
1255
- </span>
1256
- </div>
1257
- <div
1258
- v-if="optSeriesOf[r.id]"
1259
- class="opt-series"
1260
- >
1261
- <div
1262
- v-for="ch in (optSeriesOf[r.id]?.channels ?? [])"
1263
- :key="ch.nodeId"
1264
- class="opt-ch"
1265
- >
1266
- <svg
1267
- class="opt-spark"
1268
- viewBox="0 0 240 36"
1269
- preserveAspectRatio="none"
1270
- >
1271
- <polyline :points="seriesPath(ch.points)" />
1272
- </svg>
1273
- <span class="mono">{{ ch.ch }} {{ fmtPoint(ch.points.at(-1)?.value ?? ch.points.at(-1)?.avg) }}{{ ch.unit }}</span>
1274
- </div>
1275
- <p
1276
- v-if="(optSeriesOf[r.id]?.channels.length ?? 0) === 0"
1277
- class="opt-empty"
1278
- >
1279
- {{ $t('daq.k1opte002') }}
1280
- </p>
1281
- </div>
1282
- </li>
1283
- </ul>
1284
- </template>
1285
- </div>
1286
- </section>
1287
-
1288
- <!-- 节点清单:筛选工具条(产线/设备/产线运行/状态) + 节点表 -->
1289
- <a-spin :spinning="!daq.loaded && !daq.error">
1290
- <section class="aw-tile table-card">
1291
- <div class="tbl-toolbar">
1292
- <div class="filters">
1293
- <label class="flt">
1294
- <span>{{ $t('daq.k3wj9n051') }}</span>
1295
- <select
1296
- v-model="filters.lineId"
1297
- class="inp-sel"
1298
- >
1299
- <option value="">
1300
- {{ $t('daq.k1bkl9uj052') }}
1301
- </option>
1302
- <option
1303
- v-for="l in dcw.lines"
1304
- :key="l.id"
1305
- :value="l.id"
1306
- >
1307
- {{ l.name }}
1308
- </option>
1309
- <option value="none">
1310
- {{ $t('daq.k3ootr6053') }}
1311
- </option>
1312
- </select>
1313
- </label>
1314
- <label class="flt">
1315
- <span>{{ $t('daq.k1i8rtqt054') }}</span>
1316
- <select
1317
- v-model="filters.deviceId"
1318
- class="inp-sel"
1319
- >
1320
- <option value="">
1321
- {{ $t('daq.k1bkw4m2055') }}
1322
- </option>
1323
- <option
1324
- v-for="d in boundDevices"
1325
- :key="d.id"
1326
- :value="d.id"
1327
- >
1328
- {{ d.name }}({{ d.count }})
1329
- </option>
1330
- <option value="none">
1331
- {{ $t('daq.k3own4q056') }}
1332
- </option>
1333
- </select>
1334
- </label>
1335
- <label
1336
- class="flt"
1337
- :title="$t('daq.khb8mru009')"
1338
- >
1339
- <span>{{ $t('daq.k1b2tkyv057') }}</span>
1340
- <select
1341
- v-model="filters.lineRun"
1342
- class="inp-sel"
1343
- >
1344
- <option value="">
1345
- {{ $t('daq.k3x4t1058') }}
1346
- </option>
1347
- <option value="on">
1348
- {{ $t('daq.k1nxm3p7059') }}
1349
- </option>
1350
- <option value="off">
1351
- {{ $t('daq.k1ulx34e060') }}
1352
- </option>
1353
- </select>
1354
- </label>
1355
- <label class="flt">
1356
- <span>{{ $t('daq.k1iwdfef061') }}</span>
1357
- <select
1358
- v-model="filters.state"
1359
- class="inp-sel"
1360
- >
1361
- <option value="">
1362
- {{ $t('daq.k3x4t1058') }}
1363
- </option>
1364
- <option value="ok">
1365
- {{ $t('daq.k41k5c062') }}
1366
- </option>
1367
- <option value="warn">
1368
- {{ $t('daq.k49z8v063') }}
1369
- </option>
1370
- <option value="alarm">
1371
- {{ $t('daq.k3xmid064') }}
1372
- </option>
1373
- <option value="offline">
1374
- {{ $t('daq.k44c2n065') }}
1375
- </option>
1376
- </select>
1377
- </label>
1378
- <label class="flt">
1379
- <span>{{ $t('daq.k1flttpl010') }}</span>
1380
- <select
1381
- v-model="filters.template"
1382
- class="inp-sel"
1383
- >
1384
- <option value="">
1385
- {{ $t('daq.k1flttpl011') }}
1386
- </option>
1387
- <option
1388
- v-for="t in templateOptions"
1389
- :key="t.ref"
1390
- :value="t.ref"
1391
- >
1392
- {{ t.label }}({{ t.count }})
1393
- </option>
1394
- </select>
1395
- </label>
1396
- <label class="flt">
1397
- <span>{{ $t('daq.k1fltdrv012') }}</span>
1398
- <select
1399
- v-model="filters.driver"
1400
- class="inp-sel"
1401
- >
1402
- <option value="">
1403
- {{ $t('daq.k1fltdrv013') }}
1404
- </option>
1405
- <option
1406
- v-for="d in driverOptions"
1407
- :key="d.kind"
1408
- :value="d.kind"
1409
- >
1410
- {{ d.label }}({{ d.count }})
1411
- </option>
1412
- </select>
1413
- </label>
1414
- <label
1415
- class="flt"
1416
- :title="$t('daq.k1flttim014')"
1417
- >
1418
- <span>{{ $t('daq.k1flttim015') }}</span>
1419
- <select
1420
- v-model="filters.time"
1421
- class="inp-sel"
1422
- >
1423
- <option value="">
1424
- {{ $t('daq.k1flttim016') }}
1425
- </option>
1426
- <option value="live">
1427
- {{ $t('daq.k1timlv017') }}
1428
- </option>
1429
- <option value="hour">
1430
- {{ $t('daq.k1timhr018') }}
1431
- </option>
1432
- <option value="day">
1433
- {{ $t('daq.k1timdy019') }}
1434
- </option>
1435
- <option value="old">
1436
- {{ $t('daq.k1timld020') }}
1437
- </option>
1438
- <option value="never">
1439
- {{ $t('daq.k1timnv021') }}
1440
- </option>
1441
- </select>
1442
- </label>
1443
- <label class="flt">
1444
- <span>{{ $t('daq.k1fltsrc022') }}</span>
1445
- <input
1446
- v-model="filters.search"
1447
- class="inp-sel flt-search"
1448
- type="search"
1449
- :placeholder="$t('daq.k1fltsph023')"
1450
- >
1451
- </label>
1452
- <button
1453
- v-if="hasFilters"
1454
- class="mini-btn clear-btn"
1455
- @click="clearFilters"
1456
- >
1457
- {{ $t('daq.k1fygck2066') }}
1458
- </button>
1459
- </div>
1460
- <span class="count mono">{{ filteredNodes.length }} / {{ daq.nodes.length }} {{ $t('daq.k45uio067') }}</span>
1461
- </div>
1462
- <!-- 实时告警 + 实时事件:告警 = server 权威未确认报警(WS 直推/可确认);事件 = 全操作摘要轨(详情在日志管理) -->
1463
- <div class="ops-panels">
1464
- <div
1465
- class="ops-panel alarm-panel"
1466
- :class="{ live: daq.alarms.length > 0 }"
1467
- >
1468
- <div class="panel-head">
1469
- <span class="i-tabler-bell-ringing bell" />
1470
- <b>{{ $t('daq.k1alarm141') }}</b>
1471
- <span
1472
- class="mono cnt"
1473
- :class="{ hot: daq.alarms.length > 0 }"
1474
- >{{ daq.alarms.length }}</span>
1475
- </div>
1476
- <div class="panel-body">
1477
- <p
1478
- v-if="daq.alarms.length === 0"
1479
- class="panel-empty"
1480
- >
1481
- {{ $t('daq.k1evt001') }}
1482
- </p>
1483
- <div
1484
- v-for="a in daq.alarms"
1485
- v-else
1486
- :key="a.id"
1487
- class="alarm-row"
1488
- >
1489
- <span class="i-tabler-alert-triangle tri" />
1490
- <b>{{ a.nodeName }}</b>
1491
- <span class="mono dim">{{ a.metric }}</span>
1492
- <span class="mono">{{ a.value }} {{ a.rule === 'lt-min' ? '<' : '>' }} {{ a.threshold }}</span>
1493
- <small
1494
- v-if="a.escalation > 0"
1495
- class="esc mono"
1496
- >+{{ a.escalation }}</small>
1497
- <button
1498
- class="mini-btn ack"
1499
- @click="ackOne(a.id)"
1500
- >
1501
- {{ $t('daq.k1ack142') }}
1502
- </button>
1503
- </div>
1504
- </div>
1505
- </div>
1506
- <div class="ops-panel event-panel">
1507
- <div class="panel-head">
1508
- <span class="i-tabler-activity" />
1509
- <b>{{ $t('daq.k1evt002') }}</b>
1510
- <NuxtLink
1511
- class="panel-link"
1512
- to="/logs"
1513
- >
1514
- {{ $t('daq.k1evt003') }}<span class="i-tabler-arrow-right" />
1515
- </NuxtLink>
1516
- </div>
1517
- <div class="panel-body">
1518
- <p
1519
- v-if="opsLog.recent.length === 0"
1520
- class="panel-empty"
1521
- >
1522
- {{ $t('daq.k1evt004') }}
1523
- </p>
1524
- <div
1525
- v-for="(r, i) in opsLog.recent"
1526
- v-else
1527
- :key="`${r.at}:${r.actor}:${r.action}:${i}`"
1528
- class="evt-row"
1529
- >
1530
- <span class="mono dim t">{{ opsTime(r.at) }}</span>
1531
- <span
1532
- class="src-badge"
1533
- :class="r.actorKind"
1534
- >{{ r.actorKind === 'agent' ? 'Agent' : r.actorKind === 'user' ? $t('logs.src.user') : $t('logs.src.system') }}</span>
1535
- <span
1536
- class="kind-chip"
1537
- :class="r.kind"
1538
- >{{ opsKindLabel(r.kind) }}</span>
1539
- <span
1540
- class="txt"
1541
- :title="r.summary"
1542
- >{{ r.summary }}</span>
1543
- </div>
1544
- </div>
1545
- </div>
1546
- </div>
1547
- <table
1548
- ref="nodesTableRef"
1549
- class="nodes-table"
1550
- >
1551
- <thead>
1552
- <tr>
1553
- <th>{{ $t('daq.k45uio067') }}</th>
1554
- <th>{{ $t('daq.k42w8s068') }}</th>
1555
- <th>{{ $t('daq.k3mv305069') }}</th>
1556
- <th>{{ $t('daq.k1trnd001') }}</th>
1557
- <th>{{ $t('daq.k1l6g2ga070') }}</th>
1558
- <th>{{ $t('daq.k3zi0nf071') }}</th>
1559
- <th>{{ $t('daq.k4a0la072') }}</th>
1560
- <th>{{ $t('daq.k3wj9n051') }}</th>
1561
- <th :title="$t('daq.k1runtipc131')">
1562
- {{ $t('daq.k1b2tkyv057') }}
1563
- </th>
1564
- <th :title="$t('daq.k1prdrtip132')">
1565
- {{ $t('daq.k1prodrc128') }}
1566
- </th>
1567
- <th>{{ $t('daq.k1i8rtqt054') }}</th>
1568
- <th class="right">
1569
- {{ $t('daq.k40aa6073') }}
1570
- </th>
1571
- </tr>
1572
- </thead>
1573
- <tbody>
1574
- <!-- 上占位行:撑起"已滚过"的高度,让滚动条与真实全量列表一致 -->
1575
- <tr
1576
- v-if="padTopPx > 0"
1577
- class="vs-pad"
1578
- aria-hidden="true"
1579
- >
1580
- <td
1581
- :colspan="11"
1582
- :style="{ height: padTopPx + 'px' }"
1583
- />
1584
- </tr>
1585
- <WorkshopDaqNodeRow
1586
- v-for="n in visibleNodes"
1587
- :key="n.id"
1588
- :n="n"
1589
- />
1590
- <!-- 下占位行 -->
1591
- <tr
1592
- v-if="padBottomPx > 0"
1593
- class="vs-pad"
1594
- aria-hidden="true"
1595
- >
1596
- <td
1597
- :colspan="11"
1598
- :style="{ height: padBottomPx + 'px' }"
1599
- />
1600
- </tr>
1601
- <tr v-if="daq.loaded && daq.nodes.length === 0">
1602
- <td colspan="11">
1603
- <div
1604
- class="pane-empty"
1605
- style="min-height: 120px;"
1606
- >
1607
- <p class="pe-sub">
1608
- {{ $t('daq.kvhjyc3075') }}
1609
- </p>
1610
- </div>
1611
- </td>
1612
- </tr>
1613
- <tr v-else-if="daq.loaded && filteredNodes.length === 0">
1614
- <td colspan="11">
1615
- <div
1616
- class="pane-empty"
1617
- style="min-height: 90px;"
1618
- >
1619
- <p class="pe-sub">
1620
- {{ $t('daq.k1w20qew076') }}
1621
- </p>
1622
- </div>
1623
- </td>
1624
- </tr>
1625
- </tbody>
1626
- </table>
1627
- </section>
1628
- </a-spin>
1629
-
1630
- <p
1631
- v-if="daq.error"
1632
- class="err"
1633
- >
1634
- {{ daq.error }}(<NuxtLink to="/workshop">{{ $t('daq.k1bhhheq077') }}</NuxtLink>)
1635
- </p>
1636
- </div>
1637
- </template>
1638
-
1639
- <style scoped>
1640
- .page { padding: 4px; }
1641
- h1 { margin: 2px 0 4px; font-size: 30px; font-weight: 400; letter-spacing: -0.015em; }
1642
- .sub { margin: 0; font-size: 12.5px; opacity: 0.6; }
1643
- .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
1644
- .dim { opacity: 0.55; }
1645
-
1646
- .badges { display: flex; gap: 8px; }
1647
- .badge {
1648
- padding: 3px 10px;
1649
- font-size: 11px;
1650
- letter-spacing: 0.05em;
1651
- color: var(--ink-soft);
1652
- border: 1px solid var(--line-strong);
1653
- border-radius: var(--radius-chip);
1654
- }
1655
-
1656
- .ctrl-card {
1657
- display: flex;
1658
- flex-direction: column;
1659
- gap: 11px;
1660
- padding: 12px 18px 11px;
1661
- margin-bottom: 14px;
1662
- }
1663
- .ctrl-row {
1664
- display: flex;
1665
- flex-wrap: wrap;
1666
- gap: 14px;
1667
- align-items: center;
1668
- justify-content: space-between;
1669
- }
1670
- /* 窄屏前先写死「不可压缩」:药丸/周期项被 flex 收缩到 min-content → 中文退化成单列竖排。
1671
- 保持自然宽度,由外层 flex-wrap 决定换行位置。 */
1672
- .ctrl-left { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; min-width: 0; }
1673
- .ctrl-left > *, .ctrl-right > * { flex: 0 0 auto; }
1674
- .aw-pill { flex: 0 0 auto; white-space: nowrap; }
1675
- /* 行内小按钮(确认/筛选/回滚…)同族:窄屏禁止中文折成两行 */
1676
- .mini-btn, .mini-act, .pill-btn, .node-toggle { white-space: nowrap; }
1677
- .aw-pill.running { background: var(--accent); }
1678
- .cycle { display: inline-flex; flex: 0 0 auto; gap: 6px; align-items: center; font-size: 12px; white-space: nowrap; color: var(--ink-faint); }
1679
- .cycle input {
1680
- width: 84px;
1681
- padding: 4px 8px;
1682
- color: var(--ink);
1683
- background: var(--paper-deep);
1684
- border: 1px solid var(--line-strong);
1685
- border-radius: var(--radius-chip);
1686
- }
1687
- .ctrl-metrics { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-width: 0; font-size: 11.5px; color: var(--ink-soft); }
1688
- .ctrl-metrics > span { white-space: nowrap; }
1689
- .ctrl-metrics .sep { opacity: 0.4; }
1690
- .ctrl-metrics .warn { color: var(--tone-warning-dot); }
1691
-
1692
- /* 产线状态带:label 固定 + 单行横向滚动(任意产线数量恒定一行高) */
1693
- .line-strip {
1694
- display: flex;
1695
- gap: 12px;
1696
- align-items: center;
1697
- padding-top: 10px;
1698
- border-top: 1px solid var(--divider-hair);
1699
- }
1700
- .strip-label {
1701
- display: inline-flex;
1702
- flex: 0 0 auto;
1703
- gap: 6px;
1704
- align-items: center;
1705
- font-size: 10.5px;
1706
- letter-spacing: 0.08em;
1707
- text-transform: uppercase;
1708
- color: var(--ink-faint);
1709
- }
1710
- .strip-label small { font-size: 10px; color: var(--ink-faint); }
1711
- .strip-scroll {
1712
- display: flex;
1713
- gap: 8px;
1714
- flex: 1 1 auto;
1715
- min-width: 0;
1716
- overflow-x: auto;
1717
- padding-bottom: 2px;
1718
- scrollbar-width: thin;
1719
- scrollbar-color: var(--line-strong) transparent;
1720
- }
1721
- .strip-scroll::-webkit-scrollbar { height: 4px; }
1722
- .strip-scroll::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 2px; }
1723
- .strip-scroll::-webkit-scrollbar-track { background: transparent; }
1724
- .line-pill {
1725
- display: inline-flex;
1726
- flex: 0 0 auto;
1727
- gap: 7px;
1728
- align-items: center;
1729
- padding: 4px 12px;
1730
- font-size: 11.5px;
1731
- color: var(--ink-soft);
1732
- background: var(--paper-deep);
1733
- border: 1px solid var(--line-strong);
1734
- border-radius: var(--radius-pill);
1735
- transition: color 0.15s, border-color 0.15s, background 0.15s;
1736
- }
1737
- /* 空心点 = 待机;实心呼吸点 = 运行(状态语义承载体,非装饰) */
1738
- .line-pill .lp-dot {
1739
- width: 7px;
1740
- height: 7px;
1741
- border: 1.5px solid var(--lc, var(--ink-fainter));
1742
- border-radius: 50%;
1743
- opacity: 0.55;
1744
- }
1745
- .line-pill b { font-weight: 600; }
1746
- .line-pill small { font-size: 10.5px; color: var(--ink-faint); }
1747
- .line-pill:hover { border-color: var(--lc, var(--accent)); color: var(--ink); }
1748
- .line-pill.on {
1749
- color: var(--ink);
1750
- background: color-mix(in srgb, var(--lc) 10%, transparent);
1751
- border-color: color-mix(in srgb, var(--lc) 50%, transparent);
1752
- }
1753
- .line-pill.on .lp-dot {
1754
- border-color: transparent;
1755
- background: var(--lc);
1756
- opacity: 1;
1757
- }
1758
- .line-pill.on .lp-run { color: color-mix(in srgb, var(--lc) 75%, var(--ink)); }
1759
- @media (prefers-reduced-motion: no-preference) {
1760
- .line-pill.on .lp-dot { box-shadow: 0 0 7px color-mix(in srgb, var(--lc) 75%, transparent); animation: lpPulse 1.8s ease-in-out infinite; }
1761
- }
1762
- @keyframes lpPulse {
1763
- 0%, 100% { box-shadow: 0 0 3px color-mix(in srgb, var(--lc) 55%, transparent); }
1764
- 50% { box-shadow: 0 0 9px color-mix(in srgb, var(--lc) 85%, transparent); }
1765
- }
1766
-
1767
- .table-card { overflow-x: auto; }
1768
- /* 窗口化占位行:只提供高度,不参与绘制/命中,也不继承单元格内距与下边框
1769
- (否则占位行会自带一条分割线和一个 9px 内距,滚动高度算不准) */
1770
- .vs-pad td { padding: 0 !important; border: 0 !important; }
1771
- .nodes-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
1772
- /* 离屏行跳过布局/绘制(102 行长表;contain-intrinsic-size 给出占位高度防滚动条跳动) */
1773
- .nodes-table tbody tr { content-visibility: auto; contain-intrinsic-size: auto 42px; }
1774
- /* 单元格规则须穿透行组件(:deep):行组件是渲染隔离单元,其内部 td 只带子作用域,
1775
- 父页 scoped 选择器不命中 —— 行内一切样式见 DaqNodeRow.vue 的 scoped 块。 */
1776
- .nodes-table th, .nodes-table :deep(td) { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--divider-hair); }
1777
- .nodes-table th {
1778
- font-size: 11px;
1779
- font-weight: 600;
1780
- letter-spacing: 0.06em;
1781
- text-transform: uppercase;
1782
- color: var(--ink-faint);
1783
- border-bottom: 1px solid var(--line-strong);
1784
- }
1785
- .nodes-table :deep(td b) { margin-left: 8px; }
1786
- .right, .nodes-table :deep(.right) { text-align: right; }
1787
-
1788
- /* ── 实时告警 + 实时事件双面板(server 权威;玻璃面板语言) ── */
1789
- .ops-panels {
1790
- display: grid;
1791
- grid-template-columns: minmax(320px, 5fr) minmax(320px, 7fr);
1792
- gap: 10px;
1793
- margin: 0 0 10px;
1794
- }
1795
- @media (max-width: 1100px) {
1796
- .ops-panels { grid-template-columns: 1fr; }
1797
- }
1798
- .ops-panel {
1799
- min-width: 0;
1800
- background: var(--surface-glass);
1801
- border: 1px solid var(--glass-line);
1802
- border-radius: 10px;
1803
- backdrop-filter: var(--aurora-blur) saturate(1.15);
1804
- }
1805
- .ops-panel.alarm-panel.live { border-color: color-mix(in srgb, var(--tone-danger-dot) 40%, var(--glass-line)); }
1806
- .panel-head {
1807
- display: flex;
1808
- gap: 7px;
1809
- align-items: center;
1810
- padding: 8px 12px;
1811
- font-size: 12.5px;
1812
- color: var(--ink-soft);
1813
- border-bottom: 1px solid var(--glass-line);
1814
- }
1815
- .panel-head .bell { color: var(--tone-danger-dot); }
1816
- .panel-head .cnt { margin-left: auto; font-size: 11px; color: var(--ink-faint); }
1817
- .panel-head .cnt.hot { color: var(--tone-danger-dot); font-weight: 700; }
1818
- .panel-link {
1819
- display: inline-flex;
1820
- gap: 3px;
1821
- align-items: center;
1822
- margin-left: auto;
1823
- font-size: 11.5px;
1824
- color: var(--tone-info-dot);
1825
- text-decoration: none;
1826
- }
1827
- .panel-head .cnt + .panel-link { margin-left: 12px; }
1828
- .panel-body {
1829
- display: flex;
1830
- flex-direction: column;
1831
- gap: 5px;
1832
- max-height: 168px;
1833
- padding: 8px 12px;
1834
- overflow-y: auto;
1835
- }
1836
- .panel-empty { margin: 4px 0; font-size: 12px; color: var(--ink-faint); }
1837
- .alarm-row {
1838
- display: flex;
1839
- gap: 7px;
1840
- align-items: center;
1841
- padding: 5px 8px;
1842
- font-size: 12px;
1843
- color: var(--tone-danger-dot);
1844
- background: var(--tone-danger-bg);
1845
- border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 35%, transparent);
1846
- border-radius: 7px;
1847
- }
1848
- .alarm-row .tri { flex: none; }
1849
- .alarm-row b { color: var(--ink); }
1850
- .alarm-row .esc { color: var(--tone-danger-dot); }
1851
- .alarm-row .ack { margin-left: auto; }
1852
- .evt-row {
1853
- display: flex;
1854
- gap: 7px;
1855
- align-items: center;
1856
- padding: 4px 6px;
1857
- font-size: 12px;
1858
- border-radius: 6px;
1859
- }
1860
- .evt-row:hover { background: var(--frost-bg); }
1861
- .evt-row .t { flex: none; font-size: 11px; }
1862
- .evt-row .txt {
1863
- min-width: 0;
1864
- overflow: hidden;
1865
- color: var(--ink-soft);
1866
- text-overflow: ellipsis;
1867
- white-space: nowrap;
1868
- }
1869
- .src-badge {
1870
- flex: none;
1871
- padding: 1px 6px;
1872
- font-size: 10px;
1873
- border: 1px solid var(--glass-line);
1874
- border-radius: 99px;
1875
- color: var(--ink-faint);
1876
- }
1877
- .src-badge.agent { color: var(--tone-info-dot); border-color: color-mix(in srgb, var(--tone-info-dot) 40%, transparent); }
1878
- .src-badge.user { color: var(--tone-success-dot); border-color: color-mix(in srgb, var(--tone-success-dot) 40%, transparent); }
1879
- .src-badge.system { color: var(--ink-faint); }
1880
- .kind-chip {
1881
- flex: none;
1882
- padding: 1px 6px;
1883
- font-size: 10px;
1884
- color: var(--ink-soft);
1885
- background: var(--frost-bg);
1886
- border-radius: 5px;
1887
- }
1888
- .kind-chip.alarm { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
1889
- .kind-chip.write { color: var(--tone-info-dot); }
1890
- .kind-chip.rollback { color: var(--tone-warning-dot); }
1891
-
1892
- /* ── 行内单元格样式(状态 pill/实时值/趋势/驱动/产线/绑定/操作)见行组件 ── */
1893
-
1894
- .err { margin-top: 14px; font-size: 13px; color: var(--tone-danger-dot); }
1895
- @media (prefers-reduced-motion: no-preference) {
1896
- .nodes-table tbody tr:hover { background: var(--hover-tint); }
1897
- }
1898
-
1899
- .infra-banner {
1900
- display: flex;
1901
- gap: 10px;
1902
- align-items: center;
1903
- padding: 10px 14px;
1904
- margin-bottom: 14px;
1905
- color: var(--tone-warning-dot);
1906
- background: var(--tone-warning-bg);
1907
- border: 1px solid color-mix(in srgb, var(--tone-warning-dot) 40%, transparent);
1908
- border-radius: var(--radius-chip);
1909
- }
1910
- /* 筛选产线运行中:绿色语义变体 */
1911
- .infra-banner.good {
1912
- color: var(--tone-success-dot);
1913
- background: var(--tone-success-bg);
1914
- border-color: color-mix(in srgb, var(--tone-success-dot) 40%, transparent);
1915
- }
1916
- .infra-banner .txt { flex: 1 1 auto; font-size: 12.5px; line-height: 1.5; }
1917
- .infra-banner .txt a { color: var(--accent); text-decoration: underline; }
1918
- .infra-banner .txt b { font-weight: 600; }
1919
- .infra-banner .txt small { margin-left: 8px; font-size: 10.5px; opacity: 0.75; }
1920
- .infra-banner .pill-btn { flex: 0 0 auto; }
1921
-
1922
- /* ---------- 未确认报警条(S5) ---------- */
1923
- .alarm-bar {
1924
- display: flex;
1925
- flex-wrap: wrap;
1926
- gap: 12px;
1927
- align-items: center;
1928
- padding: 8px 14px;
1929
- margin-bottom: 14px;
1930
- color: var(--tone-danger-dot);
1931
- background: var(--tone-danger-bg);
1932
- border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 40%, transparent);
1933
- border-radius: var(--radius-chip);
1934
- }
1935
- .bar-label {
1936
- display: inline-flex;
1937
- flex: 0 0 auto;
1938
- gap: 6px;
1939
- align-items: center;
1940
- font-size: 11px;
1941
- font-weight: 600;
1942
- letter-spacing: 0.05em;
1943
- text-transform: uppercase;
1944
- }
1945
- .bar-label .bell { animation: bellShake 1.6s ease-in-out infinite; }
1946
- @media (prefers-reduced-motion: reduce) {
1947
- .bar-label .bell { animation: none; }
1948
- }
1949
- @keyframes bellShake {
1950
- 0%, 80%, 100% { transform: rotate(0deg); }
1951
- 85% { transform: rotate(12deg); }
1952
- 90% { transform: rotate(-10deg); }
1953
- 95% { transform: rotate(6deg); }
1954
- }
1955
- .alarm-scroll {
1956
- display: flex;
1957
- flex: 1 1 auto;
1958
- gap: 8px;
1959
- min-width: 0;
1960
- overflow-x: auto;
1961
- scrollbar-width: thin;
1962
- scrollbar-color: var(--line-strong) transparent;
1963
- }
1964
- .alarm-item {
1965
- display: inline-flex;
1966
- flex: 0 0 auto;
1967
- gap: 8px;
1968
- align-items: center;
1969
- padding: 4px 10px;
1970
- font-size: 11.5px;
1971
- color: var(--ink-soft);
1972
- background: var(--paper-deep);
1973
- border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 30%, transparent);
1974
- border-radius: var(--radius-pill);
1975
- }
1976
- .alarm-item b { font-weight: 600; }
1977
- .alarm-item .dim { opacity: 0.6; }
1978
- .alarm-item .esc { color: var(--tone-warning-dot); }
1979
- .alarm-item .ack {
1980
- padding: 2px 9px;
1981
- color: var(--paper-raised);
1982
- background: var(--tone-danger-dot);
1983
- border-color: transparent;
1984
- }
1985
- .alarm-item .ack:hover { opacity: 0.85; border-color: transparent; color: var(--paper-raised); }
1986
-
1987
- /* ---------- 添加节点向导 ---------- */
1988
- .ctrl-right { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; min-width: 0; }
1989
- .add-btn { padding: 8px 16px; font-size: 13px; white-space: nowrap; }
1990
- .modal-mask {
1991
- position: fixed;
1992
- z-index: 50;
1993
- inset: 0;
1994
- display: flex;
1995
- align-items: center;
1996
- justify-content: center;
1997
- background: var(--scrim);
1998
- backdrop-filter: blur(2px);
1999
- }
2000
- .modal {
2001
- width: 640px;
2002
- max-width: 94vw;
2003
- max-height: 88vh;
2004
- overflow-y: auto;
2005
- padding: 22px 24px;
2006
- background: var(--surface-glass-strong);
2007
- backdrop-filter: var(--aurora-blur);
2008
- border: 1px solid var(--glass-line);
2009
- border-radius: var(--radius-panel);
2010
- box-shadow: var(--glass-edge), var(--shadow-float);
2011
- }
2012
- .m-title { margin: 0 0 14px; font-size: 17px; }
2013
- .seg-row { display: flex; gap: 8px; margin-bottom: 14px; }
2014
- .seg {
2015
- flex: 1;
2016
- padding: 8px 0;
2017
- font-size: 13px;
2018
- cursor: pointer;
2019
- color: var(--ink-faint);
2020
- background: var(--paper-deep);
2021
- border: 1px solid var(--line);
2022
- border-radius: var(--radius-chip);
2023
- }
2024
- .seg.on {
2025
- font-weight: 600;
2026
- color: var(--on-accent);
2027
- background: var(--accent);
2028
- border-color: var(--accent);
2029
- }
2030
- .f-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
2031
- .driver-form { grid-template-columns: repeat(3, 1fr); }
2032
- .cal-form { grid-template-columns: 2fr 1fr; align-items: end; }
2033
- .f { display: flex; flex-direction: column; gap: 4px; font-size: 11.5px; color: var(--ink-faint); }
2034
- .f em { margin-left: 3px; font-style: normal; color: var(--tone-danger-dot); }
2035
- .inp {
2036
- width: 100%;
2037
- padding: 6px 9px;
2038
- font-size: 12.5px;
2039
- color: var(--ink);
2040
- background: var(--paper-deep);
2041
- border: 1px solid var(--line-strong);
2042
- border-radius: var(--radius-chip);
2043
- }
2044
- .hint { font-size: 10px; color: var(--ink-faint); }
2045
- .test-row { display: flex; gap: 10px; align-items: center; margin: 6px 0 4px; }
2046
- .test-result { font-family: var(--font-mono); font-size: 11px; }
2047
- .test-result.ok { color: var(--tone-success-dot); }
2048
- .test-result.bad { color: var(--tone-danger-dot); }
2049
- .m-err { margin: 8px 0 0; font-size: 12px; color: var(--tone-danger-dot); }
2050
- .m-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
2051
- .aw-pill.outline { color: var(--ink); background: var(--paper-raised); border-color: var(--line-strong); }
2052
-
2053
- /* ---------- 模板管理 ---------- */
2054
- .sec-label {
2055
- margin: 14px 0 6px;
2056
- font-size: 11px;
2057
- font-weight: 600;
2058
- letter-spacing: 0.06em;
2059
- text-transform: uppercase;
2060
- color: var(--ink-faint);
2061
- }
2062
- .tpl-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
2063
- .tpl-table td { padding: 7px 8px; border-bottom: 1px solid var(--divider-hair); }
2064
- .tpl-table td b { margin-right: 8px; }
2065
- .tpl-table .range { font-size: 11.5px; color: var(--ink-soft); }
2066
- .tpl-table .empty { color: var(--ink-faint); font-size: 12px; text-align: center; padding: 14px 0; }
2067
- .tpl-table .actions { white-space: nowrap; }
2068
-
2069
- /* ---------- 节点筛选工具条 ---------- */
2070
- .tbl-toolbar {
2071
- display: flex;
2072
- flex-wrap: wrap;
2073
- gap: 10px;
2074
- align-items: flex-end;
2075
- justify-content: space-between;
2076
- padding: 11px 14px;
2077
- border-bottom: 1px solid var(--divider-hair);
2078
- }
2079
- .filters { display: flex; flex-wrap: wrap; gap: 9px; align-items: flex-end; }
2080
- .flt {
2081
- display: flex;
2082
- flex-direction: column;
2083
- gap: 3px;
2084
- font-size: 10px;
2085
- letter-spacing: 0.05em;
2086
- text-transform: uppercase;
2087
- color: var(--ink-faint);
2088
- }
2089
- .inp-sel {
2090
- min-width: 112px;
2091
- padding: 4px 8px;
2092
- font-size: 11.5px;
2093
- color: var(--ink);
2094
- background: var(--paper-deep);
2095
- border: 1px solid var(--line-strong);
2096
- border-radius: var(--radius-chip);
2097
- transition: border-color 0.15s;
2098
- }
2099
- .inp-sel:focus { outline: none; border-color: var(--accent); }
2100
- .clear-btn { margin-bottom: 1px; }
2101
- /* 名称/ID 搜索:与下拉同族,宽度独立(复合筛选的最后一个自由维度) */
2102
- .flt-search {
2103
- width: 168px;
2104
- min-width: 0;
2105
- }
2106
- .flt-search::placeholder { color: var(--ink-faint); }
2107
- .count { padding-bottom: 4px; font-size: 11px; color: var(--ink-faint); }
2108
-
2109
- /* ── 行内产线/绑定/运行态样式见 DaqNodeRow.vue scoped 块 ── */
2110
-
2111
- /* ── Agent 优化记录面板(调控闭环;与全站玻璃语言同族) ── */
2112
- .opt-card { padding: 0; }
2113
- .opt-head {
2114
- display: flex;
2115
- gap: 9px;
2116
- align-items: center;
2117
- width: 100%;
2118
- padding: 12px 16px;
2119
- font-size: 13.5px;
2120
- color: var(--ink);
2121
- background: transparent;
2122
- border: 0;
2123
- cursor: pointer;
2124
- }
2125
- .opt-head > .i-tabler-flask { font-size: 16px; color: var(--tone-info-dot); }
2126
- .opt-count {
2127
- padding: 1px 8px;
2128
- font-size: 10.5px;
2129
- color: var(--tone-info-dot);
2130
- background: var(--tone-info-bg);
2131
- border-radius: 999px;
2132
- }
2133
- .opt-chevron {
2134
- width: 7px;
2135
- height: 7px;
2136
- margin-left: auto;
2137
- border-right: 1.5px solid var(--ink-faint);
2138
- border-bottom: 1.5px solid var(--ink-faint);
2139
- transform: rotate(45deg);
2140
- transition: transform var(--transition-base);
2141
- }
2142
- .opt-chevron.open { transform: rotate(225deg); }
2143
- /* 手风琴展开/收起(occasional 频次;状态指示;height 豁免 = 手风琴;进出对称 260ms;
2144
- * 收起态 visibility 联动,内部链接/按钮不可聚焦) */
2145
- .opt-body {
2146
- display: flex;
2147
- flex-direction: column;
2148
- gap: 10px;
2149
- max-height: 0;
2150
- padding: 0 16px;
2151
- overflow: hidden;
2152
- visibility: hidden;
2153
- opacity: 0;
2154
- transition:
2155
- max-height 260ms cubic-bezier(0.22, 0.68, 0.36, 1),
2156
- opacity 200ms ease,
2157
- visibility 260ms;
2158
- }
2159
- .opt-body.open {
2160
- max-height: 64vh;
2161
- padding-bottom: 14px;
2162
- overflow-y: auto;
2163
- visibility: visible;
2164
- opacity: 1;
2165
- }
2166
- .opt-filters { display: flex; gap: 8px; align-items: center; }
2167
- .opt-empty { font-size: 11.5px; color: var(--ink-faint); }
2168
- .opt-list { display: flex; flex-direction: column; gap: 7px; margin: 0; padding: 0; list-style: none; }
2169
- .opt-row {
2170
- display: flex;
2171
- flex-direction: column;
2172
- gap: 5px;
2173
- padding: 9px 12px;
2174
- background: var(--frost-bg);
2175
- border: 1px solid var(--glass-line);
2176
- border-radius: 10px;
2177
- }
2178
- .opt-row.open { border-color: color-mix(in srgb, var(--tone-warning-dot) 36%, var(--glass-line)); }
2179
- .opt-row.rolled-back { border-color: color-mix(in srgb, var(--tone-danger-dot) 30%, var(--glass-line)); }
2180
- .opt-line1 { display: flex; gap: 9px; align-items: center; min-width: 0; }
2181
- .opt-status {
2182
- flex: none;
2183
- padding: 1px 8px;
2184
- font-family: var(--font-mono);
2185
- font-size: 9.5px;
2186
- color: var(--ink-faint);
2187
- border: 1px solid var(--line-strong);
2188
- border-radius: 5px;
2189
- }
2190
- .opt-status.open { color: var(--tone-warning-dot); border-color: color-mix(in srgb, var(--tone-warning-dot) 45%, transparent); }
2191
- .opt-status.judged-keep { color: var(--tone-success-dot); border-color: color-mix(in srgb, var(--tone-success-dot) 45%, transparent); }
2192
- .opt-status.rolled-back { color: var(--tone-danger-dot); border-color: color-mix(in srgb, var(--tone-danger-dot) 45%, transparent); }
2193
- .opt-node { overflow: hidden; font-size: 12.5px; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }
2194
- .opt-params { flex: none; font-size: 11px; color: var(--tone-info-dot); }
2195
- .opt-time { flex: none; margin-left: auto; font-size: 10px; color: var(--ink-faint); }
2196
- .mini-act {
2197
- flex: none;
2198
- padding: 2px 9px;
2199
- font-size: 10.5px;
2200
- color: var(--tone-info-dot);
2201
- background: var(--tone-info-bg);
2202
- border: 0;
2203
- border-radius: 6px;
2204
- cursor: pointer;
2205
- }
2206
- .mini-act.danger { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
2207
- .mini-act:disabled { opacity: 0.5; cursor: default; }
2208
- .opt-line2 { display: flex; flex-wrap: wrap; gap: 8px; font-size: 10.5px; color: var(--ink-faint); }
2209
- .opt-judge.rollback, .opt-judge.keep, .opt-judge.uncertain { color: var(--ink-soft); }
2210
- .opt-judge b { font-weight: 600; }
2211
- .opt-open-hint { color: var(--tone-warning-dot); }
2212
- .opt-agg { color: var(--ink-faint); }
2213
- .opt-series { display: flex; flex-wrap: wrap; gap: 10px; padding-top: 4px; }
2214
- .opt-ch { display: flex; flex: 1 1 180px; gap: 8px; align-items: center; min-width: 160px; }
2215
- .opt-spark { width: 120px; height: 30px; }
2216
- .opt-spark polyline {
2217
- fill: none;
2218
- stroke: var(--tone-info-dot);
2219
- stroke-width: 1.5;
2220
- }
2221
-
2222
- /* ══════════════════════════════════════════════════════════════════════════
2223
- 页面级自适应加固(375 / 768 / 1440 三档)
2224
- 根因统一:flex 子项被压缩到 min-content → 中文按钮退化成「单列竖排」,
2225
- 左右两组控件抢同一行 → 右边缘越界。策略:先禁止压缩(nowrap),
2226
- 再在窄屏把控制条改成「整行 / 两列」分层排布;筛选区单列堆叠 → 两列网格。
2227
- ══════════════════════════════════════════════════════════════════════════ */
2228
- /* —— 标签字号地板(全档位,不只是窄屏)——
2229
- 本页所有自有小标签原为 9.5–11px,低于「标签 ≥11.5px」验收线;统一抬到 11.5px。
2230
- 注:节点表行内 10/10.5px 徽标在 app/components/workshop/DaqNodeRow.vue,不在本页职责内。 —— */
2231
- .badge, .sec-label, .flt, .hint, .strip-label, .strip-label small,
2232
- .line-pill small, .opt-status, .opt-time, .opt-line2, .mini-act,
2233
- .src-badge, .kind-chip, .evt-row .t, .opt-count,
2234
- .infra-banner .txt small { font-size: 11.5px; }
2235
- .sub { font-size: 13px; }
2236
- .alarm-item small, .alarm-row small { font-size: 11.5px; }
2237
-
2238
- @media (max-width: 900px) {
2239
- /* —— 兜底字号地板:本页任何 small/mono 小字在手持档不得低于 11.5px ——
2240
- 逐类点名容易漏(实测 infra-banner 的配方名、line-pill 的 mono 小字都漏过),
2241
- 这里用元素级兜底把整页收口;它放在本媒体块的**最后面**,
2242
- 同特异度时后写者胜,所以不会破坏上面更细的排版意图。 */
2243
- .page small,
2244
- .page small.mono,
2245
- .page small.mono b {
2246
- font-size: 11.5px;
2247
- }
2248
-
2249
- /* —— 触摸目标:报警「确认」在窄屏只有 44×22,手指点不中 —— */
2250
- .alarm-item { padding: 6px 10px; }
2251
- .alarm-item .ack, .mini-btn.ack { min-height: 32px; padding: 4px 12px; white-space: nowrap; }
2252
- .alarm-row { flex-wrap: wrap; }
2253
-
2254
- /* —— 窄屏字号地板(正文 ≥12.5px / 标签 ≥11.5px)——
2255
- 注:侧栏 8.5px/9.5px 属全局层(main.css / AppSidebar.vue),不在本页职责内 —— */
2256
- .flt, .sec-label, .hint { font-size: 11.5px; }
2257
- .inp-sel { font-size: 12.5px; }
2258
- .badge, .strip-label, .line-pill small { font-size: 11.5px; }
2259
- .ctrl-metrics { font-size: 12px; }
2260
- .evt-row { font-size: 12.5px; }
2261
- .evt-row .t, .src-badge, .kind-chip { font-size: 11.5px; }
2262
- .panel-head { font-size: 13px; }
2263
- .alarm-item, .alarm-item small, .alarm-row small, .opt-status, .mini-act,
2264
- .opt-time, .opt-line2, .clear-btn, .opt-count, .strip-label small { font-size: 11.5px; }
2265
- .tpl-table .range, .opt-empty, .panel-empty { font-size: 12px; }
2266
- .infra-banner .txt small { font-size: 11.5px; }
2267
- .m-title { font-size: 16px; }
2268
- .sub { font-size: 13px; }
2269
- }
2270
-
2271
- @media (max-width: 700px) {
2272
- /* —— 控制器总控条:暂停全部采集 整行;两个周期项两列;两个 CTA 两列 —— */
2273
- .ctrl-card { padding: 12px 14px 11px; }
2274
- .ctrl-row { flex-direction: column; align-items: stretch; gap: 12px; }
2275
- .ctrl-left { gap: 10px; }
2276
- .ctrl-left > .aw-pill { flex: 1 1 100%; }
2277
- .ctrl-left > .cycle { flex: 1 1 calc(50% - 5px); justify-content: space-between; }
2278
- .cycle input { width: 62px; }
2279
- .ctrl-right { gap: 10px; }
2280
- .ctrl-metrics { flex: 1 1 100%; }
2281
- .ctrl-right > .add-btn { flex: 1 1 calc(50% - 5px); }
2282
-
2283
- /* —— 未确认报警横条:标题独占一行,滚动轨吃满整宽(右侧内容不再被挤掉) —— */
2284
- .alarm-bar { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px 12px; }
2285
- .bar-label { justify-content: flex-start; }
2286
- .alarm-scroll { width: 100%; padding-bottom: 3px; }
2287
- .alarm-item { max-width: 100%; }
2288
-
2289
- /* —— 节点筛选工具条:单列下拉堆叠 → 两列网格 —— */
2290
- .tbl-toolbar { flex-direction: column; align-items: stretch; gap: 8px; padding: 11px 12px; }
2291
- .filters { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; }
2292
- .filters > .flt { min-width: 0; }
2293
- .inp-sel { width: 100%; min-width: 0; }
2294
- .flt-search { width: 100%; }
2295
- .clear-btn { grid-column: 1 / -1; min-height: 34px; }
2296
- .count { padding-bottom: 0; }
2297
-
2298
- /* —— Agent 优化记录:筛选区两列网格;记录行允许换行,长节点名不再被切 —— */
2299
- .opt-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
2300
- .opt-filters > .mini-act { grid-column: 1 / -1; min-height: 34px; }
2301
- .opt-line1 { flex-wrap: wrap; }
2302
- .opt-node { flex: 1 1 100%; overflow: visible; white-space: normal; }
2303
- .opt-time { margin-left: 0; }
2304
- .opt-ch { flex: 1 1 100%; }
2305
- .opt-spark { flex: 1 1 auto; width: auto; min-width: 96px; }
2306
-
2307
- /* —— 主表:压缩单元格内边距,缩短横向滚动距离(表格横滚 + 首列 sticky 是全局层设计) —— */
2308
- .nodes-table th, .nodes-table :deep(td) { padding: 7px 8px; }
2309
- .table-card { -webkit-overflow-scrolling: touch; }
2310
- .opt-body.open { max-height: 70vh; }
2311
-
2312
- /* —— 实时事件行:摘要原来只剩 141px 被省略号切掉 → 换行后独占整宽 —— */
2313
- .evt-row { flex-wrap: wrap; }
2314
- .evt-row .txt { flex: 1 1 100%; white-space: normal; overflow: visible; }
2315
- }
2316
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * 数采中心(DAQ Console)—— server 驱动数采的总控面。
4
+ * 后端能力自描述(tsdb/queue/驱动族 + 管线指标)、控制器全局启停/周期、
5
+ * 节点清单(状态/实时值/周期/绑定/驱动),点进 /daq/[id] 进入单节点专业控制台。
6
+ */
7
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
8
+ import { message } from 'ant-design-vue'
9
+ import { useDaqStream, type DaqNodeLive } from '@/app/composables/workshop/useDaqStream'
10
+ import { useDcwStream } from '@/app/composables/workshop/useDcwStream'
11
+ import { useWorkshopWs } from '@/app/composables/workshop/useWorkshopWs'
12
+ import { DAQ_TEMPLATES, DAQ_DRIVERS, DAQ_TEMPLATE_ICONS, daqKeyFromRef, type DaqDriverCatalogEntry, type DaqNodeView, type DaqNodeState, type DriverConfigField, type DriverTestResult as DaqDriverTestResult, type DaqTemplateDef, type DaqTemplateIcon } from '#shared/daq-protocol'
13
+ import { useDeviceTwins } from '@/app/composables/workshop/useDeviceTwins'
14
+ import { useOpsLog } from '@/app/composables/workshop/useOpsLog'
15
+ import { useVisibleInterval } from '@/app/composables/workshop/useVisibleInterval'
16
+
17
+ const { t: tt } = useI18n()
18
+
19
+ definePageMeta({ layout: 'default' })
20
+ useHead({ title: () => tt('titles.daq') })
21
+
22
+ const daq = useDaqStream()
23
+ const dcw = useDcwStream()
24
+ /** WS 会话:连接层即注册 scene peer —— daq.reading 实时帧直达本页(不再等 5s 轮询) */
25
+ const ws = useWorkshopWs()
26
+
27
+ /** 是否存在任意运行中的产线(横幅判定) */
28
+ const anyLineActive = computed(() => dcw.lines.some(l => dcw.lineStateOf(l.id).active))
29
+
30
+ // ---------- 运维日志(全操作统一记录):实时事件轨渲染摘要,详情在 /logs 日志管理 ----------
31
+ const opsLog = useOpsLog()
32
+
33
+ function opsKindLabel(kind: string): string {
34
+ const m: Record<string, string> = { write: tt('logs.kind.write'), manual: tt('logs.kind.manual'), alarm: tt('logs.kind.alarm'), line: tt('logs.kind.line'), recipe: tt('logs.kind.recipe'), rollback: tt('logs.kind.rollback'), daq: tt('logs.kind.daq'), system: tt('logs.kind.system') }
35
+ return m[kind] ?? (kind || tt('logs.kind.system'))
36
+ }
37
+
38
+ function opsTime(at: string): string {
39
+ const d = new Date(at)
40
+ return Number.isFinite(d.getTime())
41
+ ? `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
42
+ : ''
43
+ }
44
+
45
+ /** 未确认报警确认(失败 toast 后端可读原因;成功后 fetchAlarms 已由 store 内部刷新) */
46
+ async function ackOne(id: string): Promise<void> {
47
+ try {
48
+ await daq.ackAlarm(id)
49
+ message.success(tt('daq.k1acked144'))
50
+ }
51
+ catch (err) {
52
+ message.error(apiErrorMessage(err))
53
+ }
54
+ }
55
+
56
+ let unsub: (() => void) | null = null
57
+ onMounted(() => {
58
+ // WS 会话建连(连接层即注册 scene peer):daq.reading 实时帧直达,不再等 5s 轮询
59
+ ws.ensureConnected()
60
+ unsub = daq.ensureWsFeed()
61
+ // 运维日志实时事件轨(ops.log 帧;越限告警亦入轨 —— server 权威,详情在 /logs)
62
+ opsLog.ensureLive()
63
+ void daq.load()
64
+ // 未确认报警首轮拉取(S5;WS daq.alarm 直推 + 此后随低频刷新拍轮询兜底)
65
+ void daq.fetchAlarms()
66
+ // 产线门控状态(无活动配方不采集;状态仅展示,控制在产线运营页)
67
+ void dcw.load()
68
+ // 设备注册表(绑定设备列:显示名 + 可编辑换绑;与数字孪生同源 bind REST)
69
+ void deviceTwins.load()
70
+ // Agent 优化记录首屏预取(面板展开即有数据;展开时仍会重拉一次保新鲜)
71
+ void dcw.loadOptimizations()
72
+ // meta 指标随读数帧落库节奏低频刷新(诚实可见的管线运行数据);
73
+ // 产线运行态(横幅/产线列注记)同拍刷新 —— 开跑/停线后本页 ≤5s 收敛;
74
+ // dcw 全量快照(95+ 线/配方/历史)是重载荷且行控状态已有 WS 帧收敛,降至 15s 兜底;
75
+ // 轮询走可见性调度:后台自动降频(5s→30s / 15s→60s),回前台立即补拍,主线程不被占满
76
+ useVisibleInterval(() => {
77
+ void daq.load()
78
+ void daq.fetchAlarms()
79
+ }, 5000, { bgMs: 30000 })
80
+ useVisibleInterval(() => {
81
+ void dcw.load()
82
+ }, 15000, { bgMs: 60000 })
83
+ })
84
+ onBeforeUnmount(() => {
85
+ unsub?.()
86
+ })
87
+
88
+ // ---------- 节点筛选(产线/设备绑定/产线运行态/节点状态 + 模板/驱动/数据时间/名称搜索;全部 AND 复合) ----------
89
+ const filters = reactive({ lineId: '', deviceId: '', template: '', driver: '', lineRun: '', state: '', time: '', search: '' })
90
+ const hasFilters = computed(() => !!(filters.lineId || filters.deviceId || filters.template || filters.driver || filters.lineRun || filters.state || filters.time || filters.search.trim()))
91
+ function clearFilters(): void {
92
+ filters.lineId = ''
93
+ filters.deviceId = ''
94
+ filters.template = ''
95
+ filters.driver = ''
96
+ filters.lineRun = ''
97
+ filters.state = ''
98
+ filters.time = ''
99
+ filters.search = ''
100
+ }
101
+
102
+ /** 模板筛选选项:节点中出现过的 templateRef(计数),名称走 server 模板目录降级原文 */
103
+ const templateOptions = computed(() => {
104
+ const used = new Map<string, number>()
105
+ for (const n of daq.nodes) used.set(n.templateRef, (used.get(n.templateRef) ?? 0) + 1)
106
+ return [...used.entries()].map(([ref, count]) => ({ ref, count, label: daqTemplateRefCh(ref) }))
107
+ })
108
+
109
+ /** 驱动筛选选项:节点中出现过的驱动(计数) */
110
+ const driverOptions = computed(() => {
111
+ const used = new Map<string, number>()
112
+ for (const n of daq.nodes) used.set(n.driver, (used.get(n.driver) ?? 0) + 1)
113
+ return [...used.entries()].map(([kind, count]) => ({
114
+ kind,
115
+ count,
116
+ label: daq.meta.drivers.find(d => d.kind === kind)?.label ?? kind,
117
+ }))
118
+ })
119
+
120
+ /** 数据时间语义桶:最后采样距 now 的Fresh度(与行级 stale 判定同源的诚实时间观) */
121
+ function timeBucketOf(n: DaqNodeView): 'live' | 'hour' | 'day' | 'old' | 'never' {
122
+ if (!n.lastAt) return 'never'
123
+ const age = Date.now() - Date.parse(n.lastAt)
124
+ if (age <= 5 * 60_000) return 'live'
125
+ if (age <= 3_600_000) return 'hour'
126
+ if (age <= 86_400_000) return 'day'
127
+ return 'old'
128
+ }
129
+ function timeMatch(n: DaqNodeView): boolean {
130
+ return !filters.time || timeBucketOf(n) === filters.time
131
+ }
132
+ function searchMatch(n: DaqNodeView): boolean {
133
+ const q = filters.search.trim().toLowerCase()
134
+ if (!q) return true
135
+ return n.name.toLowerCase().includes(q) || n.id.toLowerCase().includes(q)
136
+ }
137
+
138
+ // ---------- Agent 优化记录(调控闭环;数采中心视角) ----------
139
+ const optOpen = ref(false)
140
+ /** 首次展开后保持内容挂载(手风琴动画需要);收起态内容不再重渲染 */
141
+ const optMounted = ref(false)
142
+ const optFilterLine = ref('')
143
+ const optFilterRecipe = ref('')
144
+ const optSeriesOf = ref<Record<string, { channels: Array<{ nodeId: string, nodeName: string, ch: string, unit: string, points: Array<{ at: number, value?: number, avg?: number }> }> }>>({})
145
+ const optSeriesLoading = ref('')
146
+
147
+ /** Recipe 选项:选定产线时只列该线配方,否则全量(与产线筛选级联) */
148
+ const optRecipeOptions = computed(() =>
149
+ dcw.recipes.filter(r => !optFilterLine.value || r.lineId === optFilterLine.value))
150
+
151
+ function optStatusKey(s: string): string {
152
+ const map: Record<string, string> = { 'open': 'daq.k1optst001', 'judged': 'daq.k1optst008', 'judged-keep': 'daq.k1optst002', 'rolled-back': 'daq.k1optst003', 'superseded': 'daq.k1optst004', 'superseded-manual': 'daq.k1optst005', 'closed-line-stop': 'daq.k1optst006', 'failed': 'daq.k1optst007' }
153
+ return map[s] ?? 'daq.k1optst001'
154
+ }
155
+
156
+ async function toggleOptPanel(): Promise<void> {
157
+ optOpen.value = !optOpen.value
158
+ if (optOpen.value) {
159
+ optMounted.value = true
160
+ await filterOpts()
161
+ }
162
+ }
163
+
164
+ async function filterOpts(): Promise<void> {
165
+ await dcw.loadOptimizations({
166
+ lineId: optFilterLine.value || undefined,
167
+ recipeId: optFilterRecipe.value || undefined,
168
+ })
169
+ }
170
+
171
+ /** 产线筛选变化:Recipe 级联重置(所选 Recipe 不在新产线内时) */
172
+ function onOptLineChange(): void {
173
+ if (optFilterRecipe.value && !optRecipeOptions.value.some(r => r.id === optFilterRecipe.value))
174
+ optFilterRecipe.value = ''
175
+ void filterOpts()
176
+ }
177
+
178
+ async function showOptSeries(id: string): Promise<void> {
179
+ optSeriesLoading.value = id
180
+ try {
181
+ const s = await dcw.optimizationSeries(id)
182
+ optSeriesOf.value = { ...optSeriesOf.value, [id]: s }
183
+ }
184
+ finally {
185
+ optSeriesLoading.value = ''
186
+ }
187
+ }
188
+
189
+ function seriesPath(points: Array<{ at: number, value?: number, avg?: number }>, w = 240, h = 36): string {
190
+ const vals = points.map(p => p.value ?? p.avg ?? 0).filter(Number.isFinite)
191
+ if (vals.length < 2) return ''
192
+ const min = Math.min(...vals)
193
+ const max = Math.max(...vals)
194
+ const span = max - min || 1
195
+ return vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - ((v - min) / span) * h}`).join(' ')
196
+ }
197
+
198
+ async function rollbackRecord(id: string): Promise<void> {
199
+ await dcw.rollbackOptimization(id)
200
+ await filterOpts()
201
+ }
202
+
203
+ /** 序列点换算的小数位(避免 sparkline 抖动) */
204
+ function fmtPoint(v: number | null | undefined): string {
205
+ return v == null ? '--' : String(Number(v.toFixed(2)))
206
+ }
207
+
208
+ /** 筛选下拉的设备选项:仅列出至少绑定了一个节点的设备(空设备筛选无意义) */
209
+ const boundDevices = computed<Array<{ id: string, name: string, count: number }>>(() => {
210
+ const used = new Map<string, number>()
211
+ for (const n of daq.nodes) {
212
+ for (const d of n.deviceIds ?? (n.deviceBindingId ? [n.deviceBindingId] : [])) {
213
+ used.set(d, (used.get(d) ?? 0) + 1)
214
+ }
215
+ }
216
+ return deviceTwins.twins
217
+ .filter(t => used.has(t.id))
218
+ .map(t => ({ id: t.id, name: nodeDevices.get(t.id) ?? t.name, count: used.get(t.id)! }))
219
+ })
220
+
221
+ /** 产线运行态筛选语义:on = 节点所属产线开跑中;off = 待机或未分配(均未采集) */
222
+ function lineRunMatch(n: DaqNodeView): boolean {
223
+ if (!filters.lineRun) return true
224
+ const active = !!n.lineId && dcw.lineStateOf(n.lineId).active
225
+ return filters.lineRun === 'on' ? active : !active
226
+ }
227
+
228
+ // ---------- 筛选产线的上下文横幅(运行态/产品/Recipe;未筛选到具体产线时回退全局门控提示) ----------
229
+ const filteredLine = computed(() => dcw.lines.find(l => l.id === filters.lineId) ?? null)
230
+ const filteredLineState = computed(() =>
231
+ filteredLine.value ? dcw.lineStateOf(filteredLine.value.id) : null)
232
+
233
+ // ---------- 行级展示状态(真实场景语义;筛选用,pill 呈现在 DaqNodeRow 行组件) ----------
234
+ // 状态机:disabled(人为停用) → unassigned(未挂产线,永不采集) → idle(产线未开跑,采集门控)
235
+ // → offline(网关暂停,或产线运行中却采不到新数据) → ok/warn/alarm(新鲜数据按量程/预警带/配方窗口派生)
236
+ type RowState = DaqNodeState | 'idle' | 'disabled' | 'unassigned'
237
+
238
+ /** 数据新鲜度:超过 max(4×有效周期, 12s) 无新样本视为「采不到数据」(容忍 5s REST 刷新拍与时钟偏差) */
239
+ function staleOf(n: DaqNodeView): boolean {
240
+ if (!n.lastAt) return true
241
+ const iv = n.intervalMs ?? daq.controller.defaultIntervalMs
242
+ return Date.now() - Date.parse(n.lastAt) > Math.max(iv * 4, 12_000)
243
+ }
244
+
245
+ function rowStateOf(n: DaqNodeView): RowState {
246
+ if (!n.enabled) return 'disabled'
247
+ if (!n.lineId) return 'unassigned'
248
+ if (!dcw.lineStateOf(n.lineId).active) return 'idle'
249
+ if (!daq.controller.running || staleOf(n)) return 'offline'
250
+ return n.state
251
+ }
252
+
253
+ const filteredNodes = computed<DaqNodeLive[]>(() => daq.nodes.filter((n) => {
254
+ if (filters.lineId && (filters.lineId === 'none' ? !!n.lineId : (n.lineId ?? '') !== filters.lineId)) return false
255
+ const nodeDevIds = n.deviceIds ?? (n.deviceBindingId ? [n.deviceBindingId] : [])
256
+ if (filters.deviceId && (filters.deviceId === 'none' ? nodeDevIds.length > 0 : !nodeDevIds.includes(filters.deviceId))) return false
257
+ if (filters.template && n.templateRef !== filters.template) return false
258
+ if (filters.driver && n.driver !== filters.driver) return false
259
+ if (!lineRunMatch(n)) return false
260
+ if (filters.state && rowStateOf(n) !== filters.state) return false
261
+ if (!timeMatch(n)) return false
262
+ if (!searchMatch(n)) return false
263
+ return true
264
+ }))
265
+
266
+ /* ── 节点表窗口化渲染(virtual window) ────────────────────────────────────────
267
+ * 为什么必须做:节点表是**全量**渲染的,而每一行都带实时读数 + sparkline,
268
+ * 每次读数合批(500ms)都会点亮所有行。实测 498 个节点时:
269
+ * · DOM 节点 13.5 万
270
+ * · 主线程每秒 ≈3.8 万次 DOM 变更(其中 99.6% 来自这张表)
271
+ * · 帧率掉到 24 FPS、92% 的帧超时、单帧最大间隔 361ms、长任务阻塞 936ms
272
+ * 也就是说"数据刷新太频繁导致卡顿"的根因不是刷新频率,而是**离屏行也在被补丁**。
273
+ *
274
+ * 做法:按页面滚动窗口只挂载可见行 + 上下各 OVERSCAN 行,用两条占位 <tr> 撑起
275
+ * 滚动高度。行的实际高度由 contain-intrinsic-size 固定为 42px(见样式),所以
276
+ * 窗口计算不需要测量 DOM。
277
+ *
278
+ * 为什么用**页面滚动**而不是容器滚动:这张表的滚动条是页面的(.table-card 只负责
279
+ * 横向),所以窗口要跟着 window.scrollY 走。
280
+ */
281
+ const VS_ROW_H = 42
282
+ const VS_OVERSCAN = 8
283
+ const nodesTableRef = ref<HTMLTableElement | null>(null)
284
+ const winStart = ref(0)
285
+ const winEnd = ref(60)
286
+
287
+ const visibleNodes = computed<DaqNodeLive[]>(() => {
288
+ const list = filteredNodes.value
289
+ const s = Math.min(winStart.value, Math.max(0, list.length - 1))
290
+ return list.slice(s, winEnd.value)
291
+ })
292
+ const padTopPx = computed(() => winStart.value * VS_ROW_H)
293
+ const padBottomPx = computed(() => Math.max(0, (filteredNodes.value.length - winEnd.value) * VS_ROW_H))
294
+
295
+ let winRaf = 0
296
+ function updateNodeWindow(): void {
297
+ const el = nodesTableRef.value
298
+ if (!el) return
299
+ const total = filteredNodes.value.length
300
+ const vh = window.innerHeight || 800
301
+ // 表格顶部相对文档的坐标(表头 40px 也算进"还没到第一行")
302
+ const tableTop = el.getBoundingClientRect().top + window.scrollY + 40
303
+ const firstVisible = Math.floor((window.scrollY - tableTop) / VS_ROW_H)
304
+ const count = Math.ceil(vh / VS_ROW_H) + VS_OVERSCAN * 2
305
+ const s = Math.max(0, Math.min(firstVisible - VS_OVERSCAN, Math.max(0, total - 1)))
306
+ const e = Math.max(s, Math.min(total, s + count))
307
+ if (s !== winStart.value) winStart.value = s
308
+ if (e !== winEnd.value) winEnd.value = e
309
+ }
310
+ function scheduleNodeWindow(): void {
311
+ if (winRaf) return
312
+ winRaf = requestAnimationFrame(() => {
313
+ winRaf = 0
314
+ updateNodeWindow()
315
+ })
316
+ }
317
+
318
+ onMounted(() => {
319
+ updateNodeWindow()
320
+ window.addEventListener('scroll', scheduleNodeWindow, { passive: true })
321
+ window.addEventListener('resize', scheduleNodeWindow, { passive: true })
322
+ })
323
+ onBeforeUnmount(() => {
324
+ window.removeEventListener('scroll', scheduleNodeWindow)
325
+ window.removeEventListener('resize', scheduleNodeWindow)
326
+ if (winRaf) cancelAnimationFrame(winRaf)
327
+ })
328
+ // 筛选/搜索改变行数后窗口必须重算(否则会停在旧的切片上,看起来"表空了")
329
+ watch(() => filteredNodes.value.length, () => {
330
+ winStart.value = 0
331
+ winEnd.value = Math.min(filteredNodes.value.length, winEnd.value || 60)
332
+ scheduleNodeWindow()
333
+ })
334
+
335
+ // 设备名映射(device-twins 注册表;绑定筛选下拉展示用)
336
+ const deviceTwins = useDeviceTwins()
337
+ const nodeDevices = new Map<string, string>()
338
+ watch(() => deviceTwins.twins, (list) => {
339
+ for (const t of list) nodeDevices.set(t.id, t.name)
340
+ }, { immediate: true, deep: true })
341
+
342
+ // ---------- 添加节点向导(mock / 真实场景 + 动态参数表单 + 测试连接) ----------
343
+ const addOpen = ref(false)
344
+ const addScenario = ref<'mock' | 'real'>('mock')
345
+ const addTemplate = ref(DAQ_TEMPLATES[0]?.key ?? 'temp-tc')
346
+ const addDriver = ref('modbus-tcp')
347
+ const addName = ref('')
348
+ const addInterval = ref<number | null>(null)
349
+ const addCfg = ref<Record<string, string | number>>({})
350
+ const addTransform = reactive({ kind: 'none' as 'none' | 'linear', scale: 1, offset: 0 })
351
+ const addTesting = ref(false)
352
+ const addTest = ref<DaqDriverTestResult | null>(null)
353
+ const addSaving = ref(false)
354
+ const addError = ref('')
355
+
356
+ // 驱动目录:server 权威(内置 + 协议插件自描述;REST 未返回时回落静态目录)
357
+ const driverCatalog = computed<DaqDriverCatalogEntry[]>(() =>
358
+ daq.meta.drivers.length ? daq.meta.drivers : DAQ_DRIVERS.map(d => ({ ...d })))
359
+ const addDriverMeta = computed(() => driverCatalog.value.find(d => d.kind === addDriver.value))
360
+ const addFields = computed<DriverConfigField[]>(() => addDriverMeta.value?.configFields ?? [])
361
+ const driverReady = (kind: string): boolean =>
362
+ daq.meta.drivers.find(d => d.kind === kind)?.status !== 'planned' && (daq.meta.driverAvailable?.[kind] !== false)
363
+
364
+ watch(addDriver, () => {
365
+ // 切协议:表单重置为 schema 缺省值
366
+ const cfg: Record<string, string | number> = {}
367
+ for (const f of addFields.value) {
368
+ if (f.default !== undefined) cfg[f.key] = f.default as string | number
369
+ }
370
+ addCfg.value = cfg
371
+ addTest.value = null
372
+ }, { immediate: true })
373
+
374
+ async function doTestConnection(): Promise<void> {
375
+ addTesting.value = true
376
+ addTest.value = null
377
+ try {
378
+ addTest.value = await daq.testDriver(addDriver.value, addCfg.value)
379
+ }
380
+ catch (err) {
381
+ addTest.value = { ok: false, message: apiErrorMessage(err) }
382
+ }
383
+ finally {
384
+ addTesting.value = false
385
+ }
386
+ }
387
+
388
+ async function doAddNode(): Promise<void> {
389
+ addSaving.value = true
390
+ addError.value = ''
391
+ try {
392
+ const transform = addTransform.kind === 'linear'
393
+ ? { kind: 'linear' as const, scale: Number(addTransform.scale), offset: Number(addTransform.offset) }
394
+ : undefined
395
+ if (addScenario.value === 'mock') {
396
+ await daq.createFromTemplate(`daq-${addTemplate.value}`, {
397
+ name: addName.value ? { name: addName.value }.name : undefined,
398
+ transform,
399
+ })
400
+ }
401
+ else {
402
+ // 校验必填
403
+ for (const f of addFields.value) {
404
+ if (f.required && !addCfg.value[f.key] && addCfg.value[f.key] !== 0) {
405
+ throw new Error(tt('daq.kyuadl0125', { p0: f.label }))
406
+ }
407
+ }
408
+ const tpl = daq.templates.find(t => t.key === addTemplate.value)
409
+ await daq.createFromTemplate(`daq-${addTemplate.value}`, {
410
+ name: addName.value || undefined,
411
+ driver: addDriver.value as never,
412
+ driverConfig: { ...addCfg.value },
413
+ transform,
414
+ intervalMs: addInterval.value,
415
+ unit: tpl?.unit,
416
+ min: tpl?.min,
417
+ max: tpl?.max,
418
+ decimals: tpl?.decimals,
419
+ } as never)
420
+ }
421
+ addOpen.value = false
422
+ addName.value = ''
423
+ addTest.value = null
424
+ }
425
+ catch (err) {
426
+ addError.value = apiErrorMessage(err)
427
+ }
428
+ finally {
429
+ addSaving.value = false
430
+ }
431
+ }
432
+
433
+ /** 模板通道语义(server 目录为唯一事实源;模板已删除 → 显示 templateRef 原文降级) */
434
+ function daqTemplateRefCh(templateRef: string): string {
435
+ const tpl = daq.templates.find(t => t.key === daqKeyFromRef(templateRef))
436
+ return tpl ? `${catalogTplName(tt, tpl)} · ${tpl.ch}` : templateRef || '-'
437
+ }
438
+
439
+ // ---------- 自定义信号模板管理(server 权威 CRUD;内置只读可复制) ----------
440
+ const tplOpen = ref(false)
441
+ const tplEditing = ref<string | null>(null)
442
+ const tplSaving = ref(false)
443
+ const tplError = ref('')
444
+ const confirmingDel = ref('')
445
+ const tplForm = reactive({
446
+ name: '', ch: '', code: '', unit: '',
447
+ min: 0, max: 100, base: 50, amp: 2,
448
+ decimals: 2, icon: 'thermo' as DaqTemplateIcon,
449
+ })
450
+ const ICON_LABEL: Record<DaqTemplateIcon, string> = {
451
+ thermo: tt('daq.k422b8079'), pressure: tt('daq.k3x6ff080'), tension: tt('daq.k3z9xc081'), encoder: tt('daq.k3subk4082'), camera: tt('daq.k47atw083'), gateway: tt('daq.kz7yhbj084'),
452
+ }
453
+
454
+ const customTpls = computed<DaqTemplateDef[]>(() => daq.templates.filter(t => !t.builtin))
455
+ const builtinTpls = computed<DaqTemplateDef[]>(() => daq.templates.filter(t => t.builtin))
456
+
457
+ function fillTplForm(t: DaqTemplateDef, asCopy = false): void {
458
+ tplForm.name = asCopy ? tt('daq.k2hbo3c126', { p0: catalogTplName(tt, t) }) : catalogTplName(tt, t)
459
+ tplForm.ch = t.ch
460
+ tplForm.code = t.code
461
+ tplForm.unit = t.unit
462
+ tplForm.min = t.min
463
+ tplForm.max = t.max
464
+ tplForm.base = t.base
465
+ tplForm.amp = t.amp
466
+ tplForm.decimals = t.decimals
467
+ tplForm.icon = t.icon
468
+ }
469
+
470
+ function resetTplForm(): void {
471
+ tplEditing.value = null
472
+ tplError.value = ''
473
+ confirmingDel.value = ''
474
+ fillTplForm({ key: '', name: '', code: '', ch: '', unit: '', base: 50, amp: 2, min: 0, max: 100, decimals: 2, icon: 'thermo' })
475
+ }
476
+
477
+ function editTpl(t: DaqTemplateDef): void {
478
+ tplEditing.value = t.key
479
+ tplError.value = ''
480
+ fillTplForm(t)
481
+ }
482
+
483
+ function copyTpl(t: DaqTemplateDef): void {
484
+ tplEditing.value = null
485
+ tplError.value = ''
486
+ fillTplForm(t, true)
487
+ }
488
+
489
+ async function saveTpl(): Promise<void> {
490
+ tplSaving.value = true
491
+ tplError.value = ''
492
+ try {
493
+ const num = (v: number | string): number | undefined => {
494
+ const n = Number(v)
495
+ return v === '' || v == null || !Number.isFinite(n) ? undefined : n
496
+ }
497
+ const input = {
498
+ name: tplForm.name.trim(),
499
+ ch: tplForm.ch.trim() || undefined,
500
+ code: tplForm.code.trim() || undefined,
501
+ unit: tplForm.unit.trim(),
502
+ min: num(tplForm.min)!,
503
+ max: num(tplForm.max)!,
504
+ base: num(tplForm.base),
505
+ amp: num(tplForm.amp),
506
+ decimals: num(tplForm.decimals),
507
+ icon: tplForm.icon,
508
+ }
509
+ if (tplEditing.value) await daq.updateTemplate(tplEditing.value, input)
510
+ else await daq.createTemplate(input)
511
+ resetTplForm()
512
+ }
513
+ catch (err) {
514
+ tplError.value = apiErrorMessage(err)
515
+ }
516
+ finally {
517
+ tplSaving.value = false
518
+ }
519
+ }
520
+
521
+ /** 两段式删除确认(避免误删;空目录风格与节点表一致) */
522
+ async function askDelTpl(t: DaqTemplateDef): Promise<void> {
523
+ if (confirmingDel.value !== t.key) {
524
+ confirmingDel.value = t.key
525
+ return
526
+ }
527
+ try {
528
+ await daq.removeTemplate(t.key)
529
+ }
530
+ catch (err) {
531
+ tplError.value = apiErrorMessage(err)
532
+ }
533
+ finally {
534
+ confirmingDel.value = ''
535
+ }
536
+ }
537
+
538
+ const reconnecting = ref(false)
539
+ async function doReconnect(): Promise<void> {
540
+ reconnecting.value = true
541
+ try {
542
+ await daq.reconnectInfra()
543
+ }
544
+ catch { /* 横幅仍在,30s 后台也会自动重试 */ }
545
+ finally {
546
+ reconnecting.value = false
547
+ }
548
+ }
549
+ </script>
550
+
551
+ <template>
552
+ <div class="page">
553
+ <div class="aw-page-head">
554
+ <div>
555
+ <p class="aw-kicker">
556
+ AGENTWORKSHOP / DAQ CONSOLE
557
+ </p>
558
+ <h1>{{ $t('daq.k1emg364011') }}</h1>
559
+ <p class="sub">
560
+ {{ $t('daq.k1wmg2bz012') }}
561
+ </p>
562
+ </div>
563
+ <div class="badges mono">
564
+ <span
565
+ class="badge"
566
+ :title="$t('daq.k1tsdbtip133')"
567
+ >TSDB · {{ daq.meta.tsdb }}</span>
568
+ <span
569
+ class="badge"
570
+ :title="$t('daq.k1queuet134')"
571
+ >QUEUE · {{ daq.meta.queue }}</span>
572
+ </div>
573
+ </div>
574
+
575
+ <!-- 产线门控横幅:筛选到具体产线 → 展示该产线运行态/产品/Recipe(待机可直达产线管理);
576
+ 未筛选具体产线 → 全局门控提示 -->
577
+ <div
578
+ v-if="daq.loaded && filteredLine && filteredLineState"
579
+ class="infra-banner"
580
+ :class="{ good: filteredLineState.active }"
581
+ >
582
+ <span :class="filteredLineState.active ? 'i-tabler-circle-check' : 'i-tabler-info-circle'" />
583
+ <span class="txt">
584
+ <template v-if="filteredLineState.active">
585
+ {{ $t('daq.k3ktxbr085') }}{{ filteredLine.name }}」{{ $t('daq.k15lvw0o105') }}<b>{{ filteredLineState.productName }}</b> · Recipe:<b>{{ filteredLineState.recipeName }}</b>
586
+ <small class="mono">{{ $t('daq.k400lb086') }} {{ filteredLineState.runId?.slice(0, 8) }} · {{ $t('daq.k3zkt2106') }} {{ filteredLineState.startedAt?.slice(11, 19) }} {{ $t('daq.k69vag8108') }} {{ filteredLineState.taggedSamples }} {{ $t('daq.k4118o092') }}</small>
587
+ </template>
588
+ <template v-else>
589
+ {{ $t('daq.k3ktxbr085') }}{{ filteredLine.name }}」{{ $t('daq.k12uge2o107') }}</template>
590
+ </span>
591
+ <NuxtLink
592
+ class="pill-btn"
593
+ :to="`/dcw/${filteredLine.id}`"
594
+ >
595
+ {{ filteredLineState.active ? $t('daq.k1ukoy0v093') : $t('daq.k1cg78i8109') }}
596
+ </NuxtLink>
597
+ </div>
598
+ <div
599
+ v-else-if="daq.loaded && !anyLineActive"
600
+ class="infra-banner"
601
+ >
602
+ <span class="i-tabler-info-circle" />
603
+ <span class="txt">{{ $t('daq.k19onw5w013') }}<NuxtLink to="/dcw">{{ $t('daq.k1b2tk5c014') }}</NuxtLink>{{ $t('daq.k1u9wnlg015') }}</span>
604
+ </div>
605
+
606
+ <!-- 基础设施降级横幅(MQTT/Timescale 不可达:在线采集停用 + 一键重连) -->
607
+ <div
608
+ v-if="daq.meta.infra?.degraded"
609
+ class="infra-banner"
610
+ >
611
+ <span class="i-tabler-alert-triangle" />
612
+ <span class="txt">{{ daq.meta.infra.warning }}</span>
613
+ <button
614
+ class="pill-btn"
615
+ :disabled="reconnecting"
616
+ @click="doReconnect"
617
+ >
618
+ {{ reconnecting ? $t('daq.k1ld43ur094') : $t('daq.kzg9805110') }}
619
+ </button>
620
+ </div>
621
+
622
+ <!-- 未确认报警条(S5):边缘触发产生 / 轮询收敛,确认后立即消失 -->
623
+ <div
624
+ v-if="daq.alarms.length"
625
+ class="alarm-bar"
626
+ >
627
+ <span class="bar-label">
628
+ <span class="i-tabler-bell-ringing bell" />
629
+ {{ $t('daq.k1alarm141') }}
630
+ <b class="mono">{{ daq.alarms.length }}</b>
631
+ </span>
632
+ <div class="alarm-scroll">
633
+ <span
634
+ v-for="a in daq.alarms"
635
+ :key="a.id"
636
+ class="alarm-item"
637
+ :title="$t('daq.k1esc143', { p0: a.escalation })"
638
+ >
639
+ <b>{{ a.nodeName }}</b>
640
+ <span class="mono dim">{{ a.metric }}</span>
641
+ <span class="mono">{{ a.value }} {{ a.rule === 'lt-min' ? '<' : '>' }} {{ a.threshold }}</span>
642
+ <small
643
+ v-if="a.escalation > 0"
644
+ class="esc mono"
645
+ >+{{ a.escalation }}</small>
646
+ <button
647
+ class="mini-btn ack"
648
+ @click="ackOne(a.id)"
649
+ >
650
+ {{ $t('daq.k1ack142') }}
651
+ </button>
652
+ </span>
653
+ </div>
654
+ </div>
655
+
656
+ <!-- 控制器总控条:第一行 控制/指标/操作,第二行 产线状态带 -->
657
+ <section class="aw-tile ctrl-card">
658
+ <div class="ctrl-row">
659
+ <div class="ctrl-left">
660
+ <button
661
+ class="aw-pill"
662
+ :class="{ running: daq.controller.running }"
663
+ :title="$t('daq.k1snap145')"
664
+ @click="daq.controllerAction(daq.controller.running ? 'pause' : 'resume')"
665
+ >
666
+ <span :class="daq.controller.running ? 'i-tabler-player-pause' : 'i-tabler-player-play'" />
667
+ {{ daq.controller.running ? $t('daq.kpui00095') : $t('daq.knxpdpt111') }}
668
+ </button>
669
+ <label class="cycle mono">
670
+ {{ $t('daq.k1ifahxj016') }}
671
+ <input
672
+ v-model.number="daq.controller.defaultIntervalMs"
673
+ type="number"
674
+ min="200"
675
+ max="60000"
676
+ step="100"
677
+ @change="daq.controllerAction('config', daq.controller.defaultIntervalMs)"
678
+ >ms
679
+ </label>
680
+ <label
681
+ class="cycle mono"
682
+ :title="$t('daq.k1e8lcoo001')"
683
+ >
684
+ {{ $t('daq.k1if98n0017') }}
685
+ <input
686
+ v-model.number="daq.controller.defaultPublishIntervalMs"
687
+ type="number"
688
+ min="0"
689
+ max="60000"
690
+ step="100"
691
+ @change="daq.controllerAction('config', daq.controller.defaultIntervalMs, daq.controller.defaultPublishIntervalMs)"
692
+ >ms
693
+ </label>
694
+ </div>
695
+ <div class="ctrl-right">
696
+ <span class="ctrl-metrics mono">
697
+ <span>{{ $t('daq.k45uio067') }} {{ daq.controller.nodesOnline }}/{{ daq.controller.nodesTotal }}</span>
698
+ <span class="sep">·</span>
699
+ <span :title="$t('daq.k1yztsu4002')">{{ $t('daq.k3xagp087') }} {{ daq.meta.produced }}</span>
700
+ <span class="sep">·</span>
701
+ <span :title="$t('daq.ko09iha003')">{{ $t('daq.k427eu088') }} {{ daq.meta.consumed }}</span>
702
+ <span class="sep">·</span>
703
+ <span
704
+ :class="{ warn: (daq.meta.dropped ?? 0) > 0 }"
705
+ :title="$t('daq.k1droptip135')"
706
+ >{{ $t('daq.k3w8go089') }} {{ daq.meta.dropped }}</span>
707
+ <span class="sep">·</span>
708
+ <span :title="$t('daq.kj1jbmw004')">{{ $t('daq.k3wusd090') }} {{ daq.meta.samplesStored }}</span>
709
+ </span>
710
+ <button
711
+ class="aw-pill outline add-btn"
712
+ @click="tplOpen = true; resetTplForm()"
713
+ >
714
+ <span class="i-tabler-adjustments-horizontal" />
715
+ {{ $t('daq.k1f5cv0s018') }}
716
+ </button>
717
+ <button
718
+ class="aw-pill add-btn"
719
+ @click="addOpen = true"
720
+ >
721
+ <span class="i-tabler-plus" />
722
+ {{ $t('daq.k1fn0ukb019') }}
723
+ </button>
724
+ </div>
725
+ </div>
726
+ <!-- 产线状态带:单行横向滚动,每产线一枚 pill(空心点=待机 / 实心呼吸点=运行中) -->
727
+ <div class="line-strip">
728
+ <span class="strip-label">
729
+ <span class="i-tabler-route" />
730
+ {{ $t('daq.k1b2o3b6020') }}<small class="mono">{{ dcw.lines.length }}</small>
731
+ </span>
732
+ <div class="strip-scroll">
733
+ <NuxtLink
734
+ v-for="l in dcw.lines"
735
+ :key="l.id"
736
+ class="line-pill"
737
+ :class="{ on: dcw.lineStateOf(l.id).active }"
738
+ :style="{ '--lc': l.color }"
739
+ :to="`/dcw/${l.id}`"
740
+ :title="dcw.lineStateOf(l.id).active
741
+ ? $t('daq.k3v957u120', { p0: l.name, p1: dcw.lineStateOf(l.id).productName, p2: dcw.lineStateOf(l.id).recipeName })
742
+ : $t('daq.k1x7jnru121', { p0: l.name })"
743
+ >
744
+ <span class="lp-dot" />
745
+ <b>{{ l.name }}</b>
746
+ <small
747
+ v-if="dcw.lineStateOf(l.id).active"
748
+ class="lp-run"
749
+ >{{ dcw.lineStateOf(l.id).recipeName ?? $t('daq.k3vp67i096') }}</small>
750
+ <small
751
+ v-else
752
+ class="lp-idle"
753
+ >{{ $t('daq.k3zgkk021') }}</small>
754
+ </NuxtLink>
755
+ <NuxtLink
756
+ v-if="dcw.lines.length === 0"
757
+ class="line-pill"
758
+ to="/dcw"
759
+ >
760
+ <span class="lp-dot" />
761
+ <b>{{ $t('daq.k1el12b1022') }}</b>
762
+ <small class="lp-idle">{{ $t('daq.k8jxxe8023') }}</small>
763
+ </NuxtLink>
764
+ </div>
765
+ </div>
766
+ </section>
767
+
768
+ <!-- 添加节点向导:mock 模拟 / 真实设备(协议参数 + 测试连接) -->
769
+ <div
770
+ v-if="addOpen"
771
+ class="modal-mask"
772
+ @click.self="addOpen = false"
773
+ >
774
+ <div class="modal">
775
+ <h3 class="m-title">
776
+ {{ $t('daq.k19rioqa024') }}
777
+ </h3>
778
+
779
+ <div class="seg-row">
780
+ <button
781
+ class="seg"
782
+ :class="{ on: addScenario === 'mock' }"
783
+ @click="addScenario = 'mock'"
784
+ >
785
+ {{ $t('daq.k1p5c1un025') }}
786
+ </button>
787
+ <button
788
+ class="seg"
789
+ :class="{ on: addScenario === 'real' }"
790
+ @click="addScenario = 'real'"
791
+ >
792
+ {{ $t('daq.k1cbcp3o026') }}
793
+ </button>
794
+ </div>
795
+
796
+ <div class="f-grid">
797
+ <label class="f">
798
+ <span>{{ $t('daq.k1fsgerc027') }}</span>
799
+ <select
800
+ v-model="addTemplate"
801
+ class="inp"
802
+ >
803
+ <option
804
+ v-for="t in daq.templates"
805
+ :key="t.key"
806
+ :value="t.key"
807
+ >
808
+ {{ t.name }} · {{ t.ch }}({{ t.min }}~{{ t.max }} {{ t.unit }}){{ t.builtin ? '' : $t('daq.kr45rk9097') }}
809
+ </option>
810
+ </select>
811
+ </label>
812
+ <label class="f">
813
+ <span>{{ $t('daq.k1ce2k1y028') }}</span>
814
+ <input
815
+ v-model="addName"
816
+ class="inp"
817
+ :placeholder="$t('daq.kgpxzy3005')"
818
+ >
819
+ </label>
820
+ <label
821
+ v-if="addScenario === 'real'"
822
+ class="f"
823
+ >
824
+ <span>{{ $t('daq.k13mfsfc029') }}</span>
825
+ <input
826
+ v-model.number="addInterval"
827
+ type="number"
828
+ min="1000"
829
+ max="60000"
830
+ step="500"
831
+ class="inp"
832
+ :placeholder="$t('daq.k1eg1000a136')"
833
+ >
834
+ </label>
835
+ </div>
836
+
837
+ <!-- 真实场景:协议选择 + 动态参数表单 + 测试连接 -->
838
+ <template v-if="addScenario === 'real'">
839
+ <div class="f-grid">
840
+ <label class="f">
841
+ <span>{{ $t('daq.k1kt87rx030') }}</span>
842
+ <select
843
+ v-model="addDriver"
844
+ class="inp"
845
+ >
846
+ <option
847
+ v-for="d in driverCatalog.filter(x => x.status !== 'planned')"
848
+ :key="d.kind"
849
+ :value="d.kind"
850
+ >
851
+ {{ d.label }}{{ d.plugin ? ' ⌁' : '' }}{{ driverReady(d.kind) ? '' : $t('daq.kjbqphp098') }}
852
+ </option>
853
+ </select>
854
+ </label>
855
+ </div>
856
+
857
+ <div
858
+ v-if="addFields.length"
859
+ class="f-grid driver-form"
860
+ >
861
+ <label
862
+ v-for="f in addFields"
863
+ :key="f.key"
864
+ class="f"
865
+ >
866
+ <span>{{ f.label }}<em v-if="f.required">*</em></span>
867
+ <select
868
+ v-if="f.type === 'select'"
869
+ v-model="addCfg[f.key]"
870
+ class="inp"
871
+ >
872
+ <option
873
+ v-for="o in f.options"
874
+ :key="o.value"
875
+ :value="o.value"
876
+ >
877
+ {{ o.label }}
878
+ </option>
879
+ </select>
880
+ <input
881
+ v-else
882
+ v-model="addCfg[f.key]"
883
+ :type="f.type === 'number' ? 'number' : 'text'"
884
+ :placeholder="f.placeholder"
885
+ class="inp"
886
+ >
887
+ <small
888
+ v-if="f.hint"
889
+ class="hint"
890
+ >{{ f.hint }}</small>
891
+ </label>
892
+ </div>
893
+
894
+ <div class="test-row">
895
+ <button
896
+ class="pill-btn"
897
+ :disabled="addTesting"
898
+ @click="doTestConnection"
899
+ >
900
+ {{ addTesting ? $t('daq.k1fsh720099') : $t('daq.k1fstglk112') }}
901
+ </button>
902
+ <span
903
+ v-if="addTest"
904
+ class="test-result"
905
+ :class="addTest.ok ? 'ok' : 'bad'"
906
+ >{{ addTest.ok ? '✓' : '✗' }} {{ addTest.message }}<template v-if="addTest.latencyMs != null">({{ addTest.latencyMs }}ms)</template></span>
907
+ </div>
908
+ </template>
909
+
910
+ <p
911
+ v-if="addError"
912
+ class="m-err"
913
+ >
914
+ {{ addError }}
915
+ </p>
916
+
917
+ <div class="m-actions">
918
+ <button
919
+ class="aw-pill outline"
920
+ @click="addOpen = false"
921
+ >
922
+ {{ $t('daq.k3xdnn031') }}
923
+ </button>
924
+ <button
925
+ class="aw-pill"
926
+ :disabled="addSaving || (addScenario === 'real' && addTest != null && !addTest.ok)"
927
+ :title="addScenario === 'real' && !(addTest && addTest.ok) ? $t('daq.k1needtst137') : ''"
928
+ @click="doAddNode"
929
+ >
930
+ {{ addSaving ? $t('daq.k1bg4759100') : (addScenario === 'real' ? $t('daq.k7pxjxo113') : $t('daq.k1bge46t118')) }}
931
+ </button>
932
+ </div>
933
+ </div>
934
+ </div>
935
+
936
+ <!-- 模板管理:自定义模板增删改 + 内置模板复制 -->
937
+ <div
938
+ v-if="tplOpen"
939
+ class="modal-mask"
940
+ @click.self="tplOpen = false"
941
+ >
942
+ <div class="modal">
943
+ <h3 class="m-title">
944
+ {{ $t('daq.k11mv96s032') }}
945
+ </h3>
946
+
947
+ <p class="sec-label">
948
+ {{ $t('daq.ktji4ky033') }}
949
+ </p>
950
+ <table class="tpl-table">
951
+ <tbody>
952
+ <tr
953
+ v-for="t in customTpls"
954
+ :key="t.key"
955
+ >
956
+ <td>
957
+ <b>{{ t.name }}</b>
958
+ <small class="mono dim">{{ t.code }}</small>
959
+ </td>
960
+ <td class="mono range">
961
+ {{ t.min }}~{{ t.max }} {{ t.unit }} · {{ t.decimals }} {{ $t('daq.k48oi091') }}
962
+ </td>
963
+ <td class="right actions">
964
+ <button
965
+ class="mini-btn"
966
+ @click="editTpl(t)"
967
+ >
968
+ {{ $t('daq.k45eb0034') }}
969
+ </button>
970
+ <button
971
+ class="mini-btn danger"
972
+ @click="askDelTpl(t)"
973
+ >
974
+ {{ confirmingDel === t.key ? $t('daq.k1hhheu3101') : $t('daq.k3xakp114') }}
975
+ </button>
976
+ </td>
977
+ </tr>
978
+ <tr v-if="!customTpls.length">
979
+ <td
980
+ colspan="3"
981
+ class="empty"
982
+ >
983
+ {{ $t('daq.kztvzbo035') }}
984
+ </td>
985
+ </tr>
986
+ </tbody>
987
+ </table>
988
+
989
+ <p class="sec-label">
990
+ {{ tplEditing ? $t('daq.k1iiph0s102') : $t('daq.k1efixrj115') }}
991
+ </p>
992
+ <div class="f-grid">
993
+ <label class="f">
994
+ <span>{{ $t('daq.k3xhia036') }}<em>*</em></span>
995
+ <input
996
+ v-model="tplForm.name"
997
+ class="inp"
998
+ :placeholder="$t('daq.k5opd2t006')"
999
+ >
1000
+ </label>
1001
+ <label class="f">
1002
+ <span>{{ $t('daq.k3x4ef037') }}<em>*</em></span>
1003
+ <input
1004
+ v-model="tplForm.unit"
1005
+ class="inp"
1006
+ :placeholder="$t('daq.unitPh')"
1007
+ >
1008
+ </label>
1009
+ <label class="f">
1010
+ <span>{{ $t('daq.k1l477m0038') }}</span>
1011
+ <input
1012
+ v-model="tplForm.ch"
1013
+ class="inp"
1014
+ :placeholder="$t('daq.kk8o4tl007')"
1015
+ >
1016
+ </label>
1017
+ <label class="f">
1018
+ <span>{{ $t('daq.k1ayxrqb039') }}</span>
1019
+ <input
1020
+ v-model="tplForm.code"
1021
+ class="inp"
1022
+ :placeholder="$t('daq.kzn3l7l008')"
1023
+ >
1024
+ </label>
1025
+ <label class="f">
1026
+ <span>{{ $t('daq.k1l9jv5m040') }}<em>*</em></span>
1027
+ <input
1028
+ v-model.number="tplForm.min"
1029
+ type="number"
1030
+ class="inp"
1031
+ >
1032
+ </label>
1033
+ <label class="f">
1034
+ <span>{{ $t('daq.k1l9jv4p041') }}<em>*</em></span>
1035
+ <input
1036
+ v-model.number="tplForm.max"
1037
+ type="number"
1038
+ class="inp"
1039
+ >
1040
+ </label>
1041
+ <label class="f">
1042
+ <span>{{ $t('daq.k3mxmcx042') }}</span>
1043
+ <input
1044
+ v-model.number="tplForm.decimals"
1045
+ type="number"
1046
+ min="0"
1047
+ max="4"
1048
+ class="inp"
1049
+ >
1050
+ </label>
1051
+ <label class="f">
1052
+ <span>{{ $t('daq.k3xx56043') }}</span>
1053
+ <select
1054
+ v-model="tplForm.icon"
1055
+ class="inp"
1056
+ >
1057
+ <option
1058
+ v-for="ic in DAQ_TEMPLATE_ICONS"
1059
+ :key="ic"
1060
+ :value="ic"
1061
+ >
1062
+ {{ ICON_LABEL[ic] }}
1063
+ </option>
1064
+ </select>
1065
+ </label>
1066
+ <label class="f">
1067
+ <span>{{ $t('daq.k1f4eknv044') }}</span>
1068
+ <input
1069
+ v-model.number="tplForm.base"
1070
+ type="number"
1071
+ class="inp"
1072
+ >
1073
+ <small class="hint">{{ $t('daq.kytd15s045') }}</small>
1074
+ </label>
1075
+ <label class="f">
1076
+ <span>{{ $t('daq.k1f4ifpo046') }}</span>
1077
+ <input
1078
+ v-model.number="tplForm.amp"
1079
+ type="number"
1080
+ class="inp"
1081
+ >
1082
+ <small class="hint">{{ $t('daq.ksp2aji047') }}</small>
1083
+ </label>
1084
+ </div>
1085
+ <p
1086
+ v-if="tplError"
1087
+ class="m-err"
1088
+ >
1089
+ {{ tplError }}
1090
+ </p>
1091
+ <div class="m-actions">
1092
+ <button
1093
+ class="aw-pill outline"
1094
+ @click="resetTplForm"
1095
+ >
1096
+ {{ $t('daq.k48p40048') }}
1097
+ </button>
1098
+ <button
1099
+ class="pill-btn"
1100
+ :disabled="tplSaving"
1101
+ @click="saveTpl"
1102
+ >
1103
+ {{ tplSaving ? $t('daq.k1b38d59103') : (tplEditing ? $t('daq.k1b39281116') : $t('daq.k1b3dtga119')) }}
1104
+ </button>
1105
+ </div>
1106
+
1107
+ <p class="sec-label">
1108
+ {{ $t('daq.k1w8s84s049') }}
1109
+ </p>
1110
+ <table class="tpl-table">
1111
+ <tbody>
1112
+ <tr
1113
+ v-for="t in builtinTpls"
1114
+ :key="t.key"
1115
+ >
1116
+ <td>
1117
+ <b>{{ t.name }}</b>
1118
+ <small class="mono dim">{{ t.code }}</small>
1119
+ </td>
1120
+ <td class="mono range">
1121
+ {{ t.min }}~{{ t.max }} {{ t.unit }} · {{ t.decimals }} {{ $t('daq.k48oi091') }}
1122
+ </td>
1123
+ <td class="right actions">
1124
+ <button
1125
+ class="mini-btn"
1126
+ @click="copyTpl(t)"
1127
+ >
1128
+ {{ $t('daq.k3y694050') }}
1129
+ </button>
1130
+ </td>
1131
+ </tr>
1132
+ </tbody>
1133
+ </table>
1134
+ </div>
1135
+ </div>
1136
+
1137
+ <!-- Agent 优化记录(调控闭环:设定历史 / 判定 / 窗口数采;序列为客户端渲染) -->
1138
+ <section class="aw-tile opt-card">
1139
+ <button
1140
+ class="opt-head"
1141
+ @click="toggleOptPanel"
1142
+ >
1143
+ <span class="i-tabler-flask" />
1144
+ <b>{{ $t('daq.k1optt001') }}</b>
1145
+ <span class="opt-count mono">{{ dcw.optimizations.length }}</span>
1146
+ <span
1147
+ class="opt-chevron"
1148
+ :class="{ open: optOpen }"
1149
+ />
1150
+ </button>
1151
+ <div
1152
+ class="opt-body"
1153
+ :class="{ open: optOpen }"
1154
+ >
1155
+ <template v-if="optMounted">
1156
+ <div class="opt-filters">
1157
+ <select
1158
+ v-model="optFilterLine"
1159
+ class="inp-sel"
1160
+ @change="onOptLineChange"
1161
+ >
1162
+ <option value="">
1163
+ {{ $t('daq.k1optf001') }}
1164
+ </option>
1165
+ <option
1166
+ v-for="l in dcw.lines"
1167
+ :key="l.id"
1168
+ :value="l.id"
1169
+ >
1170
+ {{ l.name }}
1171
+ </option>
1172
+ </select>
1173
+ <select
1174
+ v-model="optFilterRecipe"
1175
+ class="inp-sel"
1176
+ @change="filterOpts"
1177
+ >
1178
+ <option value="">
1179
+ {{ $t('daq.k1optf004') }}
1180
+ </option>
1181
+ <option
1182
+ v-for="r in optRecipeOptions"
1183
+ :key="r.id"
1184
+ :value="r.id"
1185
+ >
1186
+ {{ r.name }}
1187
+ </option>
1188
+ </select>
1189
+ <button
1190
+ class="mini-act"
1191
+ @click="filterOpts"
1192
+ >
1193
+ {{ $t('daq.k1optf002') }}
1194
+ </button>
1195
+ </div>
1196
+ <p
1197
+ v-if="dcw.optimizations.length === 0"
1198
+ class="opt-empty"
1199
+ >
1200
+ {{ $t('daq.k1opte001') }}
1201
+ </p>
1202
+ <ul class="opt-list">
1203
+ <li
1204
+ v-for="r in dcw.optimizations.slice(0, 30)"
1205
+ :key="r.id"
1206
+ class="opt-row"
1207
+ :class="r.status"
1208
+ >
1209
+ <div class="opt-line1">
1210
+ <span
1211
+ class="opt-status"
1212
+ :class="r.status"
1213
+ >{{ $t(optStatusKey(r.status)) }}</span>
1214
+ <b class="opt-node">{{ r.nodeName }}</b>
1215
+ <span class="mono opt-params">{{ r.params[0]?.from ?? '?' }} → {{ r.params[0]?.to }}</span>
1216
+ <span class="mono opt-time">{{ r.setAt.slice(5, 16).replace('T', ' ') }}</span>
1217
+ <button
1218
+ v-if="!optSeriesOf[r.id]"
1219
+ class="mini-act"
1220
+ :disabled="optSeriesLoading === r.id"
1221
+ @click="showOptSeries(r.id)"
1222
+ >
1223
+ {{ $t('daq.k1opta001') }}
1224
+ </button>
1225
+ <button
1226
+ v-if="r.status === 'open'"
1227
+ class="mini-act danger"
1228
+ @click="rollbackRecord(r.id)"
1229
+ >
1230
+ {{ $t('daq.k1opta002') }}
1231
+ </button>
1232
+ </div>
1233
+ <div class="opt-line2 mono">
1234
+ <span
1235
+ v-if="r.judge"
1236
+ class="opt-judge"
1237
+ :class="r.judge.verdict"
1238
+ >{{ r.judge.verdict }}({{ r.judge.by }}): {{ r.judge.reason.slice(0, 70) }}</span>
1239
+ <span
1240
+ v-else-if="r.status === 'open'"
1241
+ class="opt-open-hint"
1242
+ >{{ $t('daq.k1opto001') }}</span>
1243
+ <span
1244
+ v-if="r.aggPending"
1245
+ class="opt-agg"
1246
+ >{{ $t('daq.k1optw001') }}</span>
1247
+ <span
1248
+ v-else-if="r.windowAgg && r.windowAgg.channels.length"
1249
+ class="opt-agg"
1250
+ >
1251
+ <template
1252
+ v-for="c in r.windowAgg.channels"
1253
+ :key="c.daqNodeId"
1254
+ >{{ c.ch }} {{ fmtPoint(c.min) }}~{{ fmtPoint(c.max) }}({{ $t('daq.k1optw002') }}{{ c.breaches < 0 ? '-' : c.breaches }}) </template>
1255
+ </span>
1256
+ </div>
1257
+ <div
1258
+ v-if="optSeriesOf[r.id]"
1259
+ class="opt-series"
1260
+ >
1261
+ <div
1262
+ v-for="ch in (optSeriesOf[r.id]?.channels ?? [])"
1263
+ :key="ch.nodeId"
1264
+ class="opt-ch"
1265
+ >
1266
+ <svg
1267
+ class="opt-spark"
1268
+ viewBox="0 0 240 36"
1269
+ preserveAspectRatio="none"
1270
+ >
1271
+ <polyline :points="seriesPath(ch.points)" />
1272
+ </svg>
1273
+ <span class="mono">{{ ch.ch }} {{ fmtPoint(ch.points.at(-1)?.value ?? ch.points.at(-1)?.avg) }}{{ ch.unit }}</span>
1274
+ </div>
1275
+ <p
1276
+ v-if="(optSeriesOf[r.id]?.channels.length ?? 0) === 0"
1277
+ class="opt-empty"
1278
+ >
1279
+ {{ $t('daq.k1opte002') }}
1280
+ </p>
1281
+ </div>
1282
+ </li>
1283
+ </ul>
1284
+ </template>
1285
+ </div>
1286
+ </section>
1287
+
1288
+ <!-- 节点清单:筛选工具条(产线/设备/产线运行/状态) + 节点表 -->
1289
+ <a-spin :spinning="!daq.loaded && !daq.error">
1290
+ <section class="aw-tile table-card">
1291
+ <div class="tbl-toolbar">
1292
+ <div class="filters">
1293
+ <label class="flt">
1294
+ <span>{{ $t('daq.k3wj9n051') }}</span>
1295
+ <select
1296
+ v-model="filters.lineId"
1297
+ class="inp-sel"
1298
+ >
1299
+ <option value="">
1300
+ {{ $t('daq.k1bkl9uj052') }}
1301
+ </option>
1302
+ <option
1303
+ v-for="l in dcw.lines"
1304
+ :key="l.id"
1305
+ :value="l.id"
1306
+ >
1307
+ {{ l.name }}
1308
+ </option>
1309
+ <option value="none">
1310
+ {{ $t('daq.k3ootr6053') }}
1311
+ </option>
1312
+ </select>
1313
+ </label>
1314
+ <label class="flt">
1315
+ <span>{{ $t('daq.k1i8rtqt054') }}</span>
1316
+ <select
1317
+ v-model="filters.deviceId"
1318
+ class="inp-sel"
1319
+ >
1320
+ <option value="">
1321
+ {{ $t('daq.k1bkw4m2055') }}
1322
+ </option>
1323
+ <option
1324
+ v-for="d in boundDevices"
1325
+ :key="d.id"
1326
+ :value="d.id"
1327
+ >
1328
+ {{ d.name }}({{ d.count }})
1329
+ </option>
1330
+ <option value="none">
1331
+ {{ $t('daq.k3own4q056') }}
1332
+ </option>
1333
+ </select>
1334
+ </label>
1335
+ <label
1336
+ class="flt"
1337
+ :title="$t('daq.khb8mru009')"
1338
+ >
1339
+ <span>{{ $t('daq.k1b2tkyv057') }}</span>
1340
+ <select
1341
+ v-model="filters.lineRun"
1342
+ class="inp-sel"
1343
+ >
1344
+ <option value="">
1345
+ {{ $t('daq.k3x4t1058') }}
1346
+ </option>
1347
+ <option value="on">
1348
+ {{ $t('daq.k1nxm3p7059') }}
1349
+ </option>
1350
+ <option value="off">
1351
+ {{ $t('daq.k1ulx34e060') }}
1352
+ </option>
1353
+ </select>
1354
+ </label>
1355
+ <label class="flt">
1356
+ <span>{{ $t('daq.k1iwdfef061') }}</span>
1357
+ <select
1358
+ v-model="filters.state"
1359
+ class="inp-sel"
1360
+ >
1361
+ <option value="">
1362
+ {{ $t('daq.k3x4t1058') }}
1363
+ </option>
1364
+ <option value="ok">
1365
+ {{ $t('daq.k41k5c062') }}
1366
+ </option>
1367
+ <option value="warn">
1368
+ {{ $t('daq.k49z8v063') }}
1369
+ </option>
1370
+ <option value="alarm">
1371
+ {{ $t('daq.k3xmid064') }}
1372
+ </option>
1373
+ <option value="offline">
1374
+ {{ $t('daq.k44c2n065') }}
1375
+ </option>
1376
+ </select>
1377
+ </label>
1378
+ <label class="flt">
1379
+ <span>{{ $t('daq.k1flttpl010') }}</span>
1380
+ <select
1381
+ v-model="filters.template"
1382
+ class="inp-sel"
1383
+ >
1384
+ <option value="">
1385
+ {{ $t('daq.k1flttpl011') }}
1386
+ </option>
1387
+ <option
1388
+ v-for="t in templateOptions"
1389
+ :key="t.ref"
1390
+ :value="t.ref"
1391
+ >
1392
+ {{ t.label }}({{ t.count }})
1393
+ </option>
1394
+ </select>
1395
+ </label>
1396
+ <label class="flt">
1397
+ <span>{{ $t('daq.k1fltdrv012') }}</span>
1398
+ <select
1399
+ v-model="filters.driver"
1400
+ class="inp-sel"
1401
+ >
1402
+ <option value="">
1403
+ {{ $t('daq.k1fltdrv013') }}
1404
+ </option>
1405
+ <option
1406
+ v-for="d in driverOptions"
1407
+ :key="d.kind"
1408
+ :value="d.kind"
1409
+ >
1410
+ {{ d.label }}({{ d.count }})
1411
+ </option>
1412
+ </select>
1413
+ </label>
1414
+ <label
1415
+ class="flt"
1416
+ :title="$t('daq.k1flttim014')"
1417
+ >
1418
+ <span>{{ $t('daq.k1flttim015') }}</span>
1419
+ <select
1420
+ v-model="filters.time"
1421
+ class="inp-sel"
1422
+ >
1423
+ <option value="">
1424
+ {{ $t('daq.k1flttim016') }}
1425
+ </option>
1426
+ <option value="live">
1427
+ {{ $t('daq.k1timlv017') }}
1428
+ </option>
1429
+ <option value="hour">
1430
+ {{ $t('daq.k1timhr018') }}
1431
+ </option>
1432
+ <option value="day">
1433
+ {{ $t('daq.k1timdy019') }}
1434
+ </option>
1435
+ <option value="old">
1436
+ {{ $t('daq.k1timld020') }}
1437
+ </option>
1438
+ <option value="never">
1439
+ {{ $t('daq.k1timnv021') }}
1440
+ </option>
1441
+ </select>
1442
+ </label>
1443
+ <label class="flt">
1444
+ <span>{{ $t('daq.k1fltsrc022') }}</span>
1445
+ <input
1446
+ v-model="filters.search"
1447
+ class="inp-sel flt-search"
1448
+ type="search"
1449
+ :placeholder="$t('daq.k1fltsph023')"
1450
+ >
1451
+ </label>
1452
+ <button
1453
+ v-if="hasFilters"
1454
+ class="mini-btn clear-btn"
1455
+ @click="clearFilters"
1456
+ >
1457
+ {{ $t('daq.k1fygck2066') }}
1458
+ </button>
1459
+ </div>
1460
+ <span class="count mono">{{ filteredNodes.length }} / {{ daq.nodes.length }} {{ $t('daq.k45uio067') }}</span>
1461
+ </div>
1462
+ <!-- 实时告警 + 实时事件:告警 = server 权威未确认报警(WS 直推/可确认);事件 = 全操作摘要轨(详情在日志管理) -->
1463
+ <div class="ops-panels">
1464
+ <div
1465
+ class="ops-panel alarm-panel"
1466
+ :class="{ live: daq.alarms.length > 0 }"
1467
+ >
1468
+ <div class="panel-head">
1469
+ <span class="i-tabler-bell-ringing bell" />
1470
+ <b>{{ $t('daq.k1alarm141') }}</b>
1471
+ <span
1472
+ class="mono cnt"
1473
+ :class="{ hot: daq.alarms.length > 0 }"
1474
+ >{{ daq.alarms.length }}</span>
1475
+ </div>
1476
+ <div class="panel-body">
1477
+ <p
1478
+ v-if="daq.alarms.length === 0"
1479
+ class="panel-empty"
1480
+ >
1481
+ {{ $t('daq.k1evt001') }}
1482
+ </p>
1483
+ <div
1484
+ v-for="a in daq.alarms"
1485
+ v-else
1486
+ :key="a.id"
1487
+ class="alarm-row"
1488
+ >
1489
+ <span class="i-tabler-alert-triangle tri" />
1490
+ <b>{{ a.nodeName }}</b>
1491
+ <span class="mono dim">{{ a.metric }}</span>
1492
+ <span class="mono">{{ a.value }} {{ a.rule === 'lt-min' ? '<' : '>' }} {{ a.threshold }}</span>
1493
+ <small
1494
+ v-if="a.escalation > 0"
1495
+ class="esc mono"
1496
+ >+{{ a.escalation }}</small>
1497
+ <button
1498
+ class="mini-btn ack"
1499
+ @click="ackOne(a.id)"
1500
+ >
1501
+ {{ $t('daq.k1ack142') }}
1502
+ </button>
1503
+ </div>
1504
+ </div>
1505
+ </div>
1506
+ <div class="ops-panel event-panel">
1507
+ <div class="panel-head">
1508
+ <span class="i-tabler-activity" />
1509
+ <b>{{ $t('daq.k1evt002') }}</b>
1510
+ <NuxtLink
1511
+ class="panel-link"
1512
+ to="/logs"
1513
+ >
1514
+ {{ $t('daq.k1evt003') }}<span class="i-tabler-arrow-right" />
1515
+ </NuxtLink>
1516
+ </div>
1517
+ <div class="panel-body">
1518
+ <p
1519
+ v-if="opsLog.recent.length === 0"
1520
+ class="panel-empty"
1521
+ >
1522
+ {{ $t('daq.k1evt004') }}
1523
+ </p>
1524
+ <div
1525
+ v-for="(r, i) in opsLog.recent"
1526
+ v-else
1527
+ :key="`${r.at}:${r.actor}:${r.action}:${i}`"
1528
+ class="evt-row"
1529
+ >
1530
+ <span class="mono dim t">{{ opsTime(r.at) }}</span>
1531
+ <span
1532
+ class="src-badge"
1533
+ :class="r.actorKind"
1534
+ >{{ r.actorKind === 'agent' ? 'Agent' : r.actorKind === 'user' ? $t('logs.src.user') : $t('logs.src.system') }}</span>
1535
+ <span
1536
+ class="kind-chip"
1537
+ :class="r.kind"
1538
+ >{{ opsKindLabel(r.kind) }}</span>
1539
+ <span
1540
+ class="txt"
1541
+ :title="r.summary"
1542
+ >{{ r.summary }}</span>
1543
+ </div>
1544
+ </div>
1545
+ </div>
1546
+ </div>
1547
+ <table
1548
+ ref="nodesTableRef"
1549
+ class="nodes-table"
1550
+ >
1551
+ <thead>
1552
+ <tr>
1553
+ <th>{{ $t('daq.k45uio067') }}</th>
1554
+ <th>{{ $t('daq.k42w8s068') }}</th>
1555
+ <th>{{ $t('daq.k3mv305069') }}</th>
1556
+ <th>{{ $t('daq.k1trnd001') }}</th>
1557
+ <th>{{ $t('daq.k1l6g2ga070') }}</th>
1558
+ <th>{{ $t('daq.k3zi0nf071') }}</th>
1559
+ <th>{{ $t('daq.k4a0la072') }}</th>
1560
+ <th>{{ $t('daq.k3wj9n051') }}</th>
1561
+ <th :title="$t('daq.k1runtipc131')">
1562
+ {{ $t('daq.k1b2tkyv057') }}
1563
+ </th>
1564
+ <th :title="$t('daq.k1prdrtip132')">
1565
+ {{ $t('daq.k1prodrc128') }}
1566
+ </th>
1567
+ <th>{{ $t('daq.k1i8rtqt054') }}</th>
1568
+ <th class="right">
1569
+ {{ $t('daq.k40aa6073') }}
1570
+ </th>
1571
+ </tr>
1572
+ </thead>
1573
+ <tbody>
1574
+ <!-- 上占位行:撑起"已滚过"的高度,让滚动条与真实全量列表一致 -->
1575
+ <tr
1576
+ v-if="padTopPx > 0"
1577
+ class="vs-pad"
1578
+ aria-hidden="true"
1579
+ >
1580
+ <td
1581
+ :colspan="11"
1582
+ :style="{ height: padTopPx + 'px' }"
1583
+ />
1584
+ </tr>
1585
+ <WorkshopDaqNodeRow
1586
+ v-for="n in visibleNodes"
1587
+ :key="n.id"
1588
+ :n="n"
1589
+ />
1590
+ <!-- 下占位行 -->
1591
+ <tr
1592
+ v-if="padBottomPx > 0"
1593
+ class="vs-pad"
1594
+ aria-hidden="true"
1595
+ >
1596
+ <td
1597
+ :colspan="11"
1598
+ :style="{ height: padBottomPx + 'px' }"
1599
+ />
1600
+ </tr>
1601
+ <tr v-if="daq.loaded && daq.nodes.length === 0">
1602
+ <td colspan="11">
1603
+ <div
1604
+ class="pane-empty"
1605
+ style="min-height: 120px;"
1606
+ >
1607
+ <p class="pe-sub">
1608
+ {{ $t('daq.kvhjyc3075') }}
1609
+ </p>
1610
+ </div>
1611
+ </td>
1612
+ </tr>
1613
+ <tr v-else-if="daq.loaded && filteredNodes.length === 0">
1614
+ <td colspan="11">
1615
+ <div
1616
+ class="pane-empty"
1617
+ style="min-height: 90px;"
1618
+ >
1619
+ <p class="pe-sub">
1620
+ {{ $t('daq.k1w20qew076') }}
1621
+ </p>
1622
+ </div>
1623
+ </td>
1624
+ </tr>
1625
+ </tbody>
1626
+ </table>
1627
+ </section>
1628
+ </a-spin>
1629
+
1630
+ <p
1631
+ v-if="daq.error"
1632
+ class="err"
1633
+ >
1634
+ {{ daq.error }}(<NuxtLink to="/workshop">{{ $t('daq.k1bhhheq077') }}</NuxtLink>)
1635
+ </p>
1636
+ </div>
1637
+ </template>
1638
+
1639
+ <style scoped>
1640
+ .page { padding: 4px; }
1641
+ h1 { margin: 2px 0 4px; font-size: 30px; font-weight: 400; letter-spacing: -0.015em; }
1642
+ .sub { margin: 0; font-size: 12.5px; opacity: 0.6; }
1643
+ .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
1644
+ .dim { opacity: 0.55; }
1645
+
1646
+ .badges { display: flex; gap: 8px; }
1647
+ .badge {
1648
+ padding: 3px 10px;
1649
+ font-size: 11px;
1650
+ letter-spacing: 0.05em;
1651
+ color: var(--ink-soft);
1652
+ border: 1px solid var(--line-strong);
1653
+ border-radius: var(--radius-chip);
1654
+ }
1655
+
1656
+ .ctrl-card {
1657
+ display: flex;
1658
+ flex-direction: column;
1659
+ gap: 11px;
1660
+ padding: 12px 18px 11px;
1661
+ margin-bottom: 14px;
1662
+ }
1663
+ .ctrl-row {
1664
+ display: flex;
1665
+ flex-wrap: wrap;
1666
+ gap: 14px;
1667
+ align-items: center;
1668
+ justify-content: space-between;
1669
+ }
1670
+ /* 窄屏前先写死「不可压缩」:药丸/周期项被 flex 收缩到 min-content → 中文退化成单列竖排。
1671
+ 保持自然宽度,由外层 flex-wrap 决定换行位置。 */
1672
+ .ctrl-left { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; min-width: 0; }
1673
+ .ctrl-left > *, .ctrl-right > * { flex: 0 0 auto; }
1674
+ .aw-pill { flex: 0 0 auto; white-space: nowrap; }
1675
+ /* 行内小按钮(确认/筛选/回滚…)同族:窄屏禁止中文折成两行 */
1676
+ .mini-btn, .mini-act, .pill-btn, .node-toggle { white-space: nowrap; }
1677
+ .aw-pill.running { background: var(--accent); }
1678
+ .cycle { display: inline-flex; flex: 0 0 auto; gap: 6px; align-items: center; font-size: 12px; white-space: nowrap; color: var(--ink-faint); }
1679
+ .cycle input {
1680
+ width: 84px;
1681
+ padding: 4px 8px;
1682
+ color: var(--ink);
1683
+ background: var(--paper-deep);
1684
+ border: 1px solid var(--line-strong);
1685
+ border-radius: var(--radius-chip);
1686
+ }
1687
+ .ctrl-metrics { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-width: 0; font-size: 11.5px; color: var(--ink-soft); }
1688
+ .ctrl-metrics > span { white-space: nowrap; }
1689
+ .ctrl-metrics .sep { opacity: 0.4; }
1690
+ .ctrl-metrics .warn { color: var(--tone-warning-dot); }
1691
+
1692
+ /* 产线状态带:label 固定 + 单行横向滚动(任意产线数量恒定一行高) */
1693
+ .line-strip {
1694
+ display: flex;
1695
+ gap: 12px;
1696
+ align-items: center;
1697
+ padding-top: 10px;
1698
+ border-top: 1px solid var(--divider-hair);
1699
+ }
1700
+ .strip-label {
1701
+ display: inline-flex;
1702
+ flex: 0 0 auto;
1703
+ gap: 6px;
1704
+ align-items: center;
1705
+ font-size: 10.5px;
1706
+ letter-spacing: 0.08em;
1707
+ text-transform: uppercase;
1708
+ color: var(--ink-faint);
1709
+ }
1710
+ .strip-label small { font-size: 10px; color: var(--ink-faint); }
1711
+ .strip-scroll {
1712
+ display: flex;
1713
+ gap: 8px;
1714
+ flex: 1 1 auto;
1715
+ min-width: 0;
1716
+ overflow-x: auto;
1717
+ padding-bottom: 2px;
1718
+ scrollbar-width: thin;
1719
+ scrollbar-color: var(--line-strong) transparent;
1720
+ }
1721
+ .strip-scroll::-webkit-scrollbar { height: 4px; }
1722
+ .strip-scroll::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 2px; }
1723
+ .strip-scroll::-webkit-scrollbar-track { background: transparent; }
1724
+ .line-pill {
1725
+ display: inline-flex;
1726
+ flex: 0 0 auto;
1727
+ gap: 7px;
1728
+ align-items: center;
1729
+ padding: 4px 12px;
1730
+ font-size: 11.5px;
1731
+ color: var(--ink-soft);
1732
+ background: var(--paper-deep);
1733
+ border: 1px solid var(--line-strong);
1734
+ border-radius: var(--radius-pill);
1735
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
1736
+ }
1737
+ /* 空心点 = 待机;实心呼吸点 = 运行(状态语义承载体,非装饰) */
1738
+ .line-pill .lp-dot {
1739
+ width: 7px;
1740
+ height: 7px;
1741
+ border: 1.5px solid var(--lc, var(--ink-fainter));
1742
+ border-radius: 50%;
1743
+ opacity: 0.55;
1744
+ }
1745
+ .line-pill b { font-weight: 600; }
1746
+ .line-pill small { font-size: 10.5px; color: var(--ink-faint); }
1747
+ .line-pill:hover { border-color: var(--lc, var(--accent)); color: var(--ink); }
1748
+ .line-pill.on {
1749
+ color: var(--ink);
1750
+ background: color-mix(in srgb, var(--lc) 10%, transparent);
1751
+ border-color: color-mix(in srgb, var(--lc) 50%, transparent);
1752
+ }
1753
+ .line-pill.on .lp-dot {
1754
+ border-color: transparent;
1755
+ background: var(--lc);
1756
+ opacity: 1;
1757
+ }
1758
+ .line-pill.on .lp-run { color: color-mix(in srgb, var(--lc) 75%, var(--ink)); }
1759
+ @media (prefers-reduced-motion: no-preference) {
1760
+ .line-pill.on .lp-dot { box-shadow: 0 0 7px color-mix(in srgb, var(--lc) 75%, transparent); animation: lpPulse 1.8s ease-in-out infinite; }
1761
+ }
1762
+ @keyframes lpPulse {
1763
+ 0%, 100% { box-shadow: 0 0 3px color-mix(in srgb, var(--lc) 55%, transparent); }
1764
+ 50% { box-shadow: 0 0 9px color-mix(in srgb, var(--lc) 85%, transparent); }
1765
+ }
1766
+
1767
+ .table-card { overflow-x: auto; }
1768
+ /* 窗口化占位行:只提供高度,不参与绘制/命中,也不继承单元格内距与下边框
1769
+ (否则占位行会自带一条分割线和一个 9px 内距,滚动高度算不准) */
1770
+ .vs-pad td { padding: 0 !important; border: 0 !important; }
1771
+ .nodes-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
1772
+ /* 离屏行跳过布局/绘制(102 行长表;contain-intrinsic-size 给出占位高度防滚动条跳动) */
1773
+ .nodes-table tbody tr { content-visibility: auto; contain-intrinsic-size: auto 42px; }
1774
+ /* 单元格规则须穿透行组件(:deep):行组件是渲染隔离单元,其内部 td 只带子作用域,
1775
+ 父页 scoped 选择器不命中 —— 行内一切样式见 DaqNodeRow.vue 的 scoped 块。 */
1776
+ .nodes-table th, .nodes-table :deep(td) { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--divider-hair); }
1777
+ .nodes-table th {
1778
+ font-size: 11px;
1779
+ font-weight: 600;
1780
+ letter-spacing: 0.06em;
1781
+ text-transform: uppercase;
1782
+ color: var(--ink-faint);
1783
+ border-bottom: 1px solid var(--line-strong);
1784
+ }
1785
+ .nodes-table :deep(td b) { margin-left: 8px; }
1786
+ .right, .nodes-table :deep(.right) { text-align: right; }
1787
+
1788
+ /* ── 实时告警 + 实时事件双面板(server 权威;玻璃面板语言) ── */
1789
+ .ops-panels {
1790
+ display: grid;
1791
+ grid-template-columns: minmax(320px, 5fr) minmax(320px, 7fr);
1792
+ gap: 10px;
1793
+ margin: 0 0 10px;
1794
+ }
1795
+ @media (max-width: 1100px) {
1796
+ .ops-panels { grid-template-columns: 1fr; }
1797
+ }
1798
+ .ops-panel {
1799
+ min-width: 0;
1800
+ background: var(--surface-glass);
1801
+ border: 1px solid var(--glass-line);
1802
+ border-radius: 10px;
1803
+ backdrop-filter: var(--aurora-blur) saturate(1.15);
1804
+ }
1805
+ .ops-panel.alarm-panel.live { border-color: color-mix(in srgb, var(--tone-danger-dot) 40%, var(--glass-line)); }
1806
+ .panel-head {
1807
+ display: flex;
1808
+ gap: 7px;
1809
+ align-items: center;
1810
+ padding: 8px 12px;
1811
+ font-size: 12.5px;
1812
+ color: var(--ink-soft);
1813
+ border-bottom: 1px solid var(--glass-line);
1814
+ }
1815
+ .panel-head .bell { color: var(--tone-danger-dot); }
1816
+ .panel-head .cnt { margin-left: auto; font-size: 11px; color: var(--ink-faint); }
1817
+ .panel-head .cnt.hot { color: var(--tone-danger-dot); font-weight: 700; }
1818
+ .panel-link {
1819
+ display: inline-flex;
1820
+ gap: 3px;
1821
+ align-items: center;
1822
+ margin-left: auto;
1823
+ font-size: 11.5px;
1824
+ color: var(--tone-info-dot);
1825
+ text-decoration: none;
1826
+ }
1827
+ .panel-head .cnt + .panel-link { margin-left: 12px; }
1828
+ .panel-body {
1829
+ display: flex;
1830
+ flex-direction: column;
1831
+ gap: 5px;
1832
+ max-height: 168px;
1833
+ padding: 8px 12px;
1834
+ overflow-y: auto;
1835
+ }
1836
+ .panel-empty { margin: 4px 0; font-size: 12px; color: var(--ink-faint); }
1837
+ .alarm-row {
1838
+ display: flex;
1839
+ gap: 7px;
1840
+ align-items: center;
1841
+ padding: 5px 8px;
1842
+ font-size: 12px;
1843
+ color: var(--tone-danger-dot);
1844
+ background: var(--tone-danger-bg);
1845
+ border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 35%, transparent);
1846
+ border-radius: 7px;
1847
+ }
1848
+ .alarm-row .tri { flex: none; }
1849
+ .alarm-row b { color: var(--ink); }
1850
+ .alarm-row .esc { color: var(--tone-danger-dot); }
1851
+ .alarm-row .ack { margin-left: auto; }
1852
+ .evt-row {
1853
+ display: flex;
1854
+ gap: 7px;
1855
+ align-items: center;
1856
+ padding: 4px 6px;
1857
+ font-size: 12px;
1858
+ border-radius: 6px;
1859
+ }
1860
+ .evt-row:hover { background: var(--frost-bg); }
1861
+ .evt-row .t { flex: none; font-size: 11px; }
1862
+ .evt-row .txt {
1863
+ min-width: 0;
1864
+ overflow: hidden;
1865
+ color: var(--ink-soft);
1866
+ text-overflow: ellipsis;
1867
+ white-space: nowrap;
1868
+ }
1869
+ .src-badge {
1870
+ flex: none;
1871
+ padding: 1px 6px;
1872
+ font-size: 10px;
1873
+ border: 1px solid var(--glass-line);
1874
+ border-radius: 99px;
1875
+ color: var(--ink-faint);
1876
+ }
1877
+ .src-badge.agent { color: var(--tone-info-dot); border-color: color-mix(in srgb, var(--tone-info-dot) 40%, transparent); }
1878
+ .src-badge.user { color: var(--tone-success-dot); border-color: color-mix(in srgb, var(--tone-success-dot) 40%, transparent); }
1879
+ .src-badge.system { color: var(--ink-faint); }
1880
+ .kind-chip {
1881
+ flex: none;
1882
+ padding: 1px 6px;
1883
+ font-size: 10px;
1884
+ color: var(--ink-soft);
1885
+ background: var(--frost-bg);
1886
+ border-radius: 5px;
1887
+ }
1888
+ .kind-chip.alarm { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
1889
+ .kind-chip.write { color: var(--tone-info-dot); }
1890
+ .kind-chip.rollback { color: var(--tone-warning-dot); }
1891
+
1892
+ /* ── 行内单元格样式(状态 pill/实时值/趋势/驱动/产线/绑定/操作)见行组件 ── */
1893
+
1894
+ .err { margin-top: 14px; font-size: 13px; color: var(--tone-danger-dot); }
1895
+ @media (prefers-reduced-motion: no-preference) {
1896
+ .nodes-table tbody tr:hover { background: var(--hover-tint); }
1897
+ }
1898
+
1899
+ .infra-banner {
1900
+ display: flex;
1901
+ gap: 10px;
1902
+ align-items: center;
1903
+ padding: 10px 14px;
1904
+ margin-bottom: 14px;
1905
+ color: var(--tone-warning-dot);
1906
+ background: var(--tone-warning-bg);
1907
+ border: 1px solid color-mix(in srgb, var(--tone-warning-dot) 40%, transparent);
1908
+ border-radius: var(--radius-chip);
1909
+ }
1910
+ /* 筛选产线运行中:绿色语义变体 */
1911
+ .infra-banner.good {
1912
+ color: var(--tone-success-dot);
1913
+ background: var(--tone-success-bg);
1914
+ border-color: color-mix(in srgb, var(--tone-success-dot) 40%, transparent);
1915
+ }
1916
+ .infra-banner .txt { flex: 1 1 auto; font-size: 12.5px; line-height: 1.5; }
1917
+ .infra-banner .txt a { color: var(--accent); text-decoration: underline; }
1918
+ .infra-banner .txt b { font-weight: 600; }
1919
+ .infra-banner .txt small { margin-left: 8px; font-size: 10.5px; opacity: 0.75; }
1920
+ .infra-banner .pill-btn { flex: 0 0 auto; }
1921
+
1922
+ /* ---------- 未确认报警条(S5) ---------- */
1923
+ .alarm-bar {
1924
+ display: flex;
1925
+ flex-wrap: wrap;
1926
+ gap: 12px;
1927
+ align-items: center;
1928
+ padding: 8px 14px;
1929
+ margin-bottom: 14px;
1930
+ color: var(--tone-danger-dot);
1931
+ background: var(--tone-danger-bg);
1932
+ border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 40%, transparent);
1933
+ border-radius: var(--radius-chip);
1934
+ }
1935
+ .bar-label {
1936
+ display: inline-flex;
1937
+ flex: 0 0 auto;
1938
+ gap: 6px;
1939
+ align-items: center;
1940
+ font-size: 11px;
1941
+ font-weight: 600;
1942
+ letter-spacing: 0.05em;
1943
+ text-transform: uppercase;
1944
+ }
1945
+ .bar-label .bell { animation: bellShake 1.6s ease-in-out infinite; }
1946
+ @media (prefers-reduced-motion: reduce) {
1947
+ .bar-label .bell { animation: none; }
1948
+ }
1949
+ @keyframes bellShake {
1950
+ 0%, 80%, 100% { transform: rotate(0deg); }
1951
+ 85% { transform: rotate(12deg); }
1952
+ 90% { transform: rotate(-10deg); }
1953
+ 95% { transform: rotate(6deg); }
1954
+ }
1955
+ .alarm-scroll {
1956
+ display: flex;
1957
+ flex: 1 1 auto;
1958
+ gap: 8px;
1959
+ min-width: 0;
1960
+ overflow-x: auto;
1961
+ scrollbar-width: thin;
1962
+ scrollbar-color: var(--line-strong) transparent;
1963
+ }
1964
+ .alarm-item {
1965
+ display: inline-flex;
1966
+ flex: 0 0 auto;
1967
+ gap: 8px;
1968
+ align-items: center;
1969
+ padding: 4px 10px;
1970
+ font-size: 11.5px;
1971
+ color: var(--ink-soft);
1972
+ background: var(--paper-deep);
1973
+ border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 30%, transparent);
1974
+ border-radius: var(--radius-pill);
1975
+ }
1976
+ .alarm-item b { font-weight: 600; }
1977
+ .alarm-item .dim { opacity: 0.6; }
1978
+ .alarm-item .esc { color: var(--tone-warning-dot); }
1979
+ .alarm-item .ack {
1980
+ padding: 2px 9px;
1981
+ color: var(--paper-raised);
1982
+ background: var(--tone-danger-dot);
1983
+ border-color: transparent;
1984
+ }
1985
+ .alarm-item .ack:hover { opacity: 0.85; border-color: transparent; color: var(--paper-raised); }
1986
+
1987
+ /* ---------- 添加节点向导 ---------- */
1988
+ .ctrl-right { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; min-width: 0; }
1989
+ .add-btn { padding: 8px 16px; font-size: 13px; white-space: nowrap; }
1990
+ .modal-mask {
1991
+ position: fixed;
1992
+ z-index: 50;
1993
+ inset: 0;
1994
+ display: flex;
1995
+ align-items: center;
1996
+ justify-content: center;
1997
+ background: var(--scrim);
1998
+ backdrop-filter: blur(2px);
1999
+ }
2000
+ .modal {
2001
+ width: 640px;
2002
+ max-width: 94vw;
2003
+ max-height: 88vh;
2004
+ overflow-y: auto;
2005
+ padding: 22px 24px;
2006
+ background: var(--surface-glass-strong);
2007
+ backdrop-filter: var(--aurora-blur);
2008
+ border: 1px solid var(--glass-line);
2009
+ border-radius: var(--radius-panel);
2010
+ box-shadow: var(--glass-edge), var(--shadow-float);
2011
+ }
2012
+ .m-title { margin: 0 0 14px; font-size: 17px; }
2013
+ .seg-row { display: flex; gap: 8px; margin-bottom: 14px; }
2014
+ .seg {
2015
+ flex: 1;
2016
+ padding: 8px 0;
2017
+ font-size: 13px;
2018
+ cursor: pointer;
2019
+ color: var(--ink-faint);
2020
+ background: var(--paper-deep);
2021
+ border: 1px solid var(--line);
2022
+ border-radius: var(--radius-chip);
2023
+ }
2024
+ .seg.on {
2025
+ font-weight: 600;
2026
+ color: var(--on-accent);
2027
+ background: var(--accent);
2028
+ border-color: var(--accent);
2029
+ }
2030
+ .f-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
2031
+ .driver-form { grid-template-columns: repeat(3, 1fr); }
2032
+ .cal-form { grid-template-columns: 2fr 1fr; align-items: end; }
2033
+ .f { display: flex; flex-direction: column; gap: 4px; font-size: 11.5px; color: var(--ink-faint); }
2034
+ .f em { margin-left: 3px; font-style: normal; color: var(--tone-danger-dot); }
2035
+ .inp {
2036
+ width: 100%;
2037
+ padding: 6px 9px;
2038
+ font-size: 12.5px;
2039
+ color: var(--ink);
2040
+ background: var(--paper-deep);
2041
+ border: 1px solid var(--line-strong);
2042
+ border-radius: var(--radius-chip);
2043
+ }
2044
+ .hint { font-size: 10px; color: var(--ink-faint); }
2045
+ .test-row { display: flex; gap: 10px; align-items: center; margin: 6px 0 4px; }
2046
+ .test-result { font-family: var(--font-mono); font-size: 11px; }
2047
+ .test-result.ok { color: var(--tone-success-dot); }
2048
+ .test-result.bad { color: var(--tone-danger-dot); }
2049
+ .m-err { margin: 8px 0 0; font-size: 12px; color: var(--tone-danger-dot); }
2050
+ .m-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
2051
+ .aw-pill.outline { color: var(--ink); background: var(--paper-raised); border-color: var(--line-strong); }
2052
+
2053
+ /* ---------- 模板管理 ---------- */
2054
+ .sec-label {
2055
+ margin: 14px 0 6px;
2056
+ font-size: 11px;
2057
+ font-weight: 600;
2058
+ letter-spacing: 0.06em;
2059
+ text-transform: uppercase;
2060
+ color: var(--ink-faint);
2061
+ }
2062
+ .tpl-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
2063
+ .tpl-table td { padding: 7px 8px; border-bottom: 1px solid var(--divider-hair); }
2064
+ .tpl-table td b { margin-right: 8px; }
2065
+ .tpl-table .range { font-size: 11.5px; color: var(--ink-soft); }
2066
+ .tpl-table .empty { color: var(--ink-faint); font-size: 12px; text-align: center; padding: 14px 0; }
2067
+ .tpl-table .actions { white-space: nowrap; }
2068
+
2069
+ /* ---------- 节点筛选工具条 ---------- */
2070
+ .tbl-toolbar {
2071
+ display: flex;
2072
+ flex-wrap: wrap;
2073
+ gap: 10px;
2074
+ align-items: flex-end;
2075
+ justify-content: space-between;
2076
+ padding: 11px 14px;
2077
+ border-bottom: 1px solid var(--divider-hair);
2078
+ }
2079
+ .filters { display: flex; flex-wrap: wrap; gap: 9px; align-items: flex-end; }
2080
+ .flt {
2081
+ display: flex;
2082
+ flex-direction: column;
2083
+ gap: 3px;
2084
+ font-size: 10px;
2085
+ letter-spacing: 0.05em;
2086
+ text-transform: uppercase;
2087
+ color: var(--ink-faint);
2088
+ }
2089
+ .inp-sel {
2090
+ min-width: 112px;
2091
+ padding: 4px 8px;
2092
+ font-size: 11.5px;
2093
+ color: var(--ink);
2094
+ background: var(--paper-deep);
2095
+ border: 1px solid var(--line-strong);
2096
+ border-radius: var(--radius-chip);
2097
+ transition: border-color 0.15s;
2098
+ }
2099
+ .inp-sel:focus { outline: none; border-color: var(--accent); }
2100
+ .clear-btn { margin-bottom: 1px; }
2101
+ /* 名称/ID 搜索:与下拉同族,宽度独立(复合筛选的最后一个自由维度) */
2102
+ .flt-search {
2103
+ width: 168px;
2104
+ min-width: 0;
2105
+ }
2106
+ .flt-search::placeholder { color: var(--ink-faint); }
2107
+ .count { padding-bottom: 4px; font-size: 11px; color: var(--ink-faint); }
2108
+
2109
+ /* ── 行内产线/绑定/运行态样式见 DaqNodeRow.vue scoped 块 ── */
2110
+
2111
+ /* ── Agent 优化记录面板(调控闭环;与全站玻璃语言同族) ── */
2112
+ .opt-card { padding: 0; }
2113
+ .opt-head {
2114
+ display: flex;
2115
+ gap: 9px;
2116
+ align-items: center;
2117
+ width: 100%;
2118
+ padding: 12px 16px;
2119
+ font-size: 13.5px;
2120
+ color: var(--ink);
2121
+ background: transparent;
2122
+ border: 0;
2123
+ cursor: pointer;
2124
+ }
2125
+ .opt-head > .i-tabler-flask { font-size: 16px; color: var(--tone-info-dot); }
2126
+ .opt-count {
2127
+ padding: 1px 8px;
2128
+ font-size: 10.5px;
2129
+ color: var(--tone-info-dot);
2130
+ background: var(--tone-info-bg);
2131
+ border-radius: 999px;
2132
+ }
2133
+ .opt-chevron {
2134
+ width: 7px;
2135
+ height: 7px;
2136
+ margin-left: auto;
2137
+ border-right: 1.5px solid var(--ink-faint);
2138
+ border-bottom: 1.5px solid var(--ink-faint);
2139
+ transform: rotate(45deg);
2140
+ transition: transform var(--transition-base);
2141
+ }
2142
+ .opt-chevron.open { transform: rotate(225deg); }
2143
+ /* 手风琴展开/收起(occasional 频次;状态指示;height 豁免 = 手风琴;进出对称 260ms;
2144
+ * 收起态 visibility 联动,内部链接/按钮不可聚焦) */
2145
+ .opt-body {
2146
+ display: flex;
2147
+ flex-direction: column;
2148
+ gap: 10px;
2149
+ max-height: 0;
2150
+ padding: 0 16px;
2151
+ overflow: hidden;
2152
+ visibility: hidden;
2153
+ opacity: 0;
2154
+ transition:
2155
+ max-height 260ms cubic-bezier(0.22, 0.68, 0.36, 1),
2156
+ opacity 200ms ease,
2157
+ visibility 260ms;
2158
+ }
2159
+ .opt-body.open {
2160
+ max-height: 64vh;
2161
+ padding-bottom: 14px;
2162
+ overflow-y: auto;
2163
+ visibility: visible;
2164
+ opacity: 1;
2165
+ }
2166
+ .opt-filters { display: flex; gap: 8px; align-items: center; }
2167
+ .opt-empty { font-size: 11.5px; color: var(--ink-faint); }
2168
+ .opt-list { display: flex; flex-direction: column; gap: 7px; margin: 0; padding: 0; list-style: none; }
2169
+ .opt-row {
2170
+ display: flex;
2171
+ flex-direction: column;
2172
+ gap: 5px;
2173
+ padding: 9px 12px;
2174
+ background: var(--frost-bg);
2175
+ border: 1px solid var(--glass-line);
2176
+ border-radius: 10px;
2177
+ }
2178
+ .opt-row.open { border-color: color-mix(in srgb, var(--tone-warning-dot) 36%, var(--glass-line)); }
2179
+ .opt-row.rolled-back { border-color: color-mix(in srgb, var(--tone-danger-dot) 30%, var(--glass-line)); }
2180
+ .opt-line1 { display: flex; gap: 9px; align-items: center; min-width: 0; }
2181
+ .opt-status {
2182
+ flex: none;
2183
+ padding: 1px 8px;
2184
+ font-family: var(--font-mono);
2185
+ font-size: 9.5px;
2186
+ color: var(--ink-faint);
2187
+ border: 1px solid var(--line-strong);
2188
+ border-radius: 5px;
2189
+ }
2190
+ .opt-status.open { color: var(--tone-warning-dot); border-color: color-mix(in srgb, var(--tone-warning-dot) 45%, transparent); }
2191
+ .opt-status.judged-keep { color: var(--tone-success-dot); border-color: color-mix(in srgb, var(--tone-success-dot) 45%, transparent); }
2192
+ .opt-status.rolled-back { color: var(--tone-danger-dot); border-color: color-mix(in srgb, var(--tone-danger-dot) 45%, transparent); }
2193
+ .opt-node { overflow: hidden; font-size: 12.5px; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }
2194
+ .opt-params { flex: none; font-size: 11px; color: var(--tone-info-dot); }
2195
+ .opt-time { flex: none; margin-left: auto; font-size: 10px; color: var(--ink-faint); }
2196
+ .mini-act {
2197
+ flex: none;
2198
+ padding: 2px 9px;
2199
+ font-size: 10.5px;
2200
+ color: var(--tone-info-dot);
2201
+ background: var(--tone-info-bg);
2202
+ border: 0;
2203
+ border-radius: 6px;
2204
+ cursor: pointer;
2205
+ }
2206
+ .mini-act.danger { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
2207
+ .mini-act:disabled { opacity: 0.5; cursor: default; }
2208
+ .opt-line2 { display: flex; flex-wrap: wrap; gap: 8px; font-size: 10.5px; color: var(--ink-faint); }
2209
+ .opt-judge.rollback, .opt-judge.keep, .opt-judge.uncertain { color: var(--ink-soft); }
2210
+ .opt-judge b { font-weight: 600; }
2211
+ .opt-open-hint { color: var(--tone-warning-dot); }
2212
+ .opt-agg { color: var(--ink-faint); }
2213
+ .opt-series { display: flex; flex-wrap: wrap; gap: 10px; padding-top: 4px; }
2214
+ .opt-ch { display: flex; flex: 1 1 180px; gap: 8px; align-items: center; min-width: 160px; }
2215
+ .opt-spark { width: 120px; height: 30px; }
2216
+ .opt-spark polyline {
2217
+ fill: none;
2218
+ stroke: var(--tone-info-dot);
2219
+ stroke-width: 1.5;
2220
+ }
2221
+
2222
+ /* ══════════════════════════════════════════════════════════════════════════
2223
+ 页面级自适应加固(375 / 768 / 1440 三档)
2224
+ 根因统一:flex 子项被压缩到 min-content → 中文按钮退化成「单列竖排」,
2225
+ 左右两组控件抢同一行 → 右边缘越界。策略:先禁止压缩(nowrap),
2226
+ 再在窄屏把控制条改成「整行 / 两列」分层排布;筛选区单列堆叠 → 两列网格。
2227
+ ══════════════════════════════════════════════════════════════════════════ */
2228
+ /* —— 标签字号地板(全档位,不只是窄屏)——
2229
+ 本页所有自有小标签原为 9.5–11px,低于「标签 ≥11.5px」验收线;统一抬到 11.5px。
2230
+ 注:节点表行内 10/10.5px 徽标在 app/components/workshop/DaqNodeRow.vue,不在本页职责内。 —— */
2231
+ .badge, .sec-label, .flt, .hint, .strip-label, .strip-label small,
2232
+ .line-pill small, .opt-status, .opt-time, .opt-line2, .mini-act,
2233
+ .src-badge, .kind-chip, .evt-row .t, .opt-count,
2234
+ .infra-banner .txt small { font-size: 11.5px; }
2235
+ .sub { font-size: 13px; }
2236
+ .alarm-item small, .alarm-row small { font-size: 11.5px; }
2237
+
2238
+ @media (max-width: 900px) {
2239
+ /* —— 兜底字号地板:本页任何 small/mono 小字在手持档不得低于 11.5px ——
2240
+ 逐类点名容易漏(实测 infra-banner 的配方名、line-pill 的 mono 小字都漏过),
2241
+ 这里用元素级兜底把整页收口;它放在本媒体块的**最后面**,
2242
+ 同特异度时后写者胜,所以不会破坏上面更细的排版意图。 */
2243
+ .page small,
2244
+ .page small.mono,
2245
+ .page small.mono b {
2246
+ font-size: 11.5px;
2247
+ }
2248
+
2249
+ /* —— 触摸目标:报警「确认」在窄屏只有 44×22,手指点不中 —— */
2250
+ .alarm-item { padding: 6px 10px; }
2251
+ .alarm-item .ack, .mini-btn.ack { min-height: 32px; padding: 4px 12px; white-space: nowrap; }
2252
+ .alarm-row { flex-wrap: wrap; }
2253
+
2254
+ /* —— 窄屏字号地板(正文 ≥12.5px / 标签 ≥11.5px)——
2255
+ 注:侧栏 8.5px/9.5px 属全局层(main.css / AppSidebar.vue),不在本页职责内 —— */
2256
+ .flt, .sec-label, .hint { font-size: 11.5px; }
2257
+ .inp-sel { font-size: 12.5px; }
2258
+ .badge, .strip-label, .line-pill small { font-size: 11.5px; }
2259
+ .ctrl-metrics { font-size: 12px; }
2260
+ .evt-row { font-size: 12.5px; }
2261
+ .evt-row .t, .src-badge, .kind-chip { font-size: 11.5px; }
2262
+ .panel-head { font-size: 13px; }
2263
+ .alarm-item, .alarm-item small, .alarm-row small, .opt-status, .mini-act,
2264
+ .opt-time, .opt-line2, .clear-btn, .opt-count, .strip-label small { font-size: 11.5px; }
2265
+ .tpl-table .range, .opt-empty, .panel-empty { font-size: 12px; }
2266
+ .infra-banner .txt small { font-size: 11.5px; }
2267
+ .m-title { font-size: 16px; }
2268
+ .sub { font-size: 13px; }
2269
+ }
2270
+
2271
+ @media (max-width: 700px) {
2272
+ /* —— 控制器总控条:暂停全部采集 整行;两个周期项两列;两个 CTA 两列 —— */
2273
+ .ctrl-card { padding: 12px 14px 11px; }
2274
+ .ctrl-row { flex-direction: column; align-items: stretch; gap: 12px; }
2275
+ .ctrl-left { gap: 10px; }
2276
+ .ctrl-left > .aw-pill { flex: 1 1 100%; }
2277
+ .ctrl-left > .cycle { flex: 1 1 calc(50% - 5px); justify-content: space-between; }
2278
+ .cycle input { width: 62px; }
2279
+ .ctrl-right { gap: 10px; }
2280
+ .ctrl-metrics { flex: 1 1 100%; }
2281
+ .ctrl-right > .add-btn { flex: 1 1 calc(50% - 5px); }
2282
+
2283
+ /* —— 未确认报警横条:标题独占一行,滚动轨吃满整宽(右侧内容不再被挤掉) —— */
2284
+ .alarm-bar { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px 12px; }
2285
+ .bar-label { justify-content: flex-start; }
2286
+ .alarm-scroll { width: 100%; padding-bottom: 3px; }
2287
+ .alarm-item { max-width: 100%; }
2288
+
2289
+ /* —— 节点筛选工具条:单列下拉堆叠 → 两列网格 —— */
2290
+ .tbl-toolbar { flex-direction: column; align-items: stretch; gap: 8px; padding: 11px 12px; }
2291
+ .filters { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; }
2292
+ .filters > .flt { min-width: 0; }
2293
+ .inp-sel { width: 100%; min-width: 0; }
2294
+ .flt-search { width: 100%; }
2295
+ .clear-btn { grid-column: 1 / -1; min-height: 34px; }
2296
+ .count { padding-bottom: 0; }
2297
+
2298
+ /* —— Agent 优化记录:筛选区两列网格;记录行允许换行,长节点名不再被切 —— */
2299
+ .opt-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
2300
+ .opt-filters > .mini-act { grid-column: 1 / -1; min-height: 34px; }
2301
+ .opt-line1 { flex-wrap: wrap; }
2302
+ .opt-node { flex: 1 1 100%; overflow: visible; white-space: normal; }
2303
+ .opt-time { margin-left: 0; }
2304
+ .opt-ch { flex: 1 1 100%; }
2305
+ .opt-spark { flex: 1 1 auto; width: auto; min-width: 96px; }
2306
+
2307
+ /* —— 主表:压缩单元格内边距,缩短横向滚动距离(表格横滚 + 首列 sticky 是全局层设计) —— */
2308
+ .nodes-table th, .nodes-table :deep(td) { padding: 7px 8px; }
2309
+ .table-card { -webkit-overflow-scrolling: touch; }
2310
+ .opt-body.open { max-height: 70vh; }
2311
+
2312
+ /* —— 实时事件行:摘要原来只剩 141px 被省略号切掉 → 换行后独占整宽 —— */
2313
+ .evt-row { flex-wrap: wrap; }
2314
+ .evt-row .txt { flex: 1 1 100%; white-space: normal; overflow: visible; }
2315
+ }
2316
+ </style>