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,1562 +1,1564 @@
1
- <script setup lang="ts">
2
- import { message } from 'ant-design-vue'
3
- import { useUserStore } from '@/app/stores/workshop/user'
4
- import { useRuntimeConfigStore } from '@/app/stores/runtime-config'
5
- import { useWorkshopApi, type WorkshopPluginDto } from '@/app/composables/workshop/useWorkshopApi'
6
- import { apiErrorMessage } from '@/app/utils/api-error'
7
-
8
- const { t, locale, locales, setLocale } = useI18n()
9
- const store = useAppStore()
10
- const site = useSiteConfig()
11
- const config = useRuntimeConfig().public
12
- const userStore = useUserStore()
13
- const api = useWorkshopApi()
14
-
15
- const activeTab = ref('appearance')
16
-
17
- /* ================= 运行配置(服务端持久化 + 热重载) ================= */
18
- const rcStore = useRuntimeConfigStore()
19
- const draft = ref<Record<string, unknown>>({})
20
- const dirtyKeys = ref<Set<string>>(new Set())
21
- const savingRuntime = ref(false)
22
- const runtimeNotice = ref<{ type: 'success' | 'warning' | 'error', text: string } | null>(null)
23
-
24
- function syncRuntimeDraft() {
25
- draft.value = { ...rcStore.effective }
26
- dirtyKeys.value = new Set()
27
- }
28
- // immediate:store 由启动插件在应用初始化时拉取,进入本页时通常已 loaded=true,
29
- // 非 immediate 的 watch 永不触发 → 草稿恒空、所有值输入框显示为空(表单不可用)。
30
- watch(() => rcStore.loaded, (v) => {
31
- if (v) syncRuntimeDraft()
32
- }, { immediate: true })
33
- watch(() => rcStore.effective, () => {
34
- // 外部写入(CLI/其他窗口/文件监听)推来的变化,未编辑时才回填草稿
35
- if (dirtyKeys.value.size === 0) syncRuntimeDraft()
36
- }, { deep: true })
37
-
38
- function markDirty(key: string) {
39
- dirtyKeys.value.add(key)
40
- runtimeNotice.value = null
41
- }
42
-
43
- async function saveRuntime() {
44
- if (!dirtyKeys.value.size) return
45
- savingRuntime.value = true
46
- runtimeNotice.value = null
47
- try {
48
- const patchMap: Record<string, unknown> = {}
49
- for (const k of dirtyKeys.value) patchMap[k] = draft.value[k]
50
- const res = await rcStore.patch(patchMap)
51
- syncRuntimeDraft()
52
- const restart = res.restartRequired ?? []
53
- runtimeNotice.value = restart.length
54
- ? { type: 'warning', text: t('settings.runtime.restartNotice', { keys: restart.join(', ') }) }
55
- : { type: 'success', text: t('settings.runtime.savedLive') }
56
- }
57
- catch (e) {
58
- const err = e as { response?: { data?: { message?: string } }, message?: string }
59
- runtimeNotice.value = { type: 'error', text: err?.response?.data?.message || err?.message || String(e) }
60
- }
61
- finally {
62
- savingRuntime.value = false
63
- }
64
- }
65
-
66
- async function resetRuntimeKey(key: string) {
67
- try {
68
- await rcStore.patch({ [key]: null })
69
- // 只收敛被重置的键:整表 sync 会静默丢弃用户其他未保存编辑
70
- dirtyKeys.value.delete(key)
71
- draft.value[key] = rcStore.effective[key]
72
- runtimeNotice.value = { type: 'success', text: t('settings.runtime.keyReset', { key }) }
73
- }
74
- catch (e) {
75
- const err = e as { response?: { data?: { message?: string } }, message?: string }
76
- runtimeNotice.value = { type: 'error', text: err?.response?.data?.message || err?.message || String(e) }
77
- }
78
- }
79
-
80
- async function resetAllRuntime() {
81
- try {
82
- await rcStore.resetAll()
83
- syncRuntimeDraft()
84
- runtimeNotice.value = { type: 'success', text: t('settings.runtime.resetAllDone') }
85
- }
86
- catch (e) {
87
- const err = e as { response?: { data?: { message?: string } }, message?: string }
88
- runtimeNotice.value = { type: 'error', text: err?.response?.data?.message || err?.message || String(e) }
89
- }
90
- }
91
-
92
- /** 内置分组的 i18n 标签(id → i18n key);插件/自建分组走自己的 label */
93
- const KNOWN_GROUP_LABELS: Record<string, string> = { server: 'settings.runtime.groupServer', app: 'settings.runtime.groupApp', api: 'settings.runtime.groupApi', theme: 'settings.runtime.groupTheme', i18n: 'settings.runtime.groupI18n', security: 'settings.runtime.groupSecurity', daq: 'settings.runtime.groupDaq', memory: 'settings.runtime.groupMemory', omp: 'settings.runtime.groupOmp', harness: 'settings.runtime.groupHarness', dcw: 'settings.runtime.groupDcw', workshop: 'settings.runtime.groupWorkshop', backup: 'settings.runtime.groupBackup', retention: 'settings.runtime.groupRetention', log: 'settings.runtime.groupLog', plugins: 'settings.runtime.groupPlugins' }
94
-
95
- /**
96
- * 分组标题解析优先级:
97
- * 1) 分组自带 labelKey(插件可本地化自己分区的标题)
98
- * 2) 服务端下发的 label 与 id 不同 —— 管理员改过名 / 插件声明的展示名
99
- * 3) 内置分组的 i18n 映射(注册表里内置组 label 就是 id,需要前端补语言)
100
- * 4) 兜底用 id
101
- */
102
- function groupLabel(g: { id: string, label?: string, labelKey?: string }): string {
103
- if (g.labelKey && t(g.labelKey) !== g.labelKey) return t(g.labelKey)
104
- if (g.label && g.label !== g.id) return g.label
105
- const known = KNOWN_GROUP_LABELS[g.id]
106
- if (known) return t(known)
107
- return g.label || g.id
108
- }
109
- function groupSourceBadge(g: { source: string, plugin?: string }): string {
110
- if (g.source === 'plugin') return g.plugin ? `${t('settings.groups.srcPlugin')}: ${g.plugin}` : t('settings.groups.srcPlugin')
111
- if (g.source === 'user') return t('settings.groups.srcUser')
112
- return t('settings.groups.srcBuiltin')
113
- }
114
- function sourceClass(s: string): string {
115
- return s === 'runtime' ? 'src-runtime' : s === 'env' ? 'src-env' : 'src-yaml'
116
- }
117
- function itemLabel(item: { label: string, labelKey?: string }): string {
118
- const k = item.labelKey
119
- return (k && t(k) !== k) ? t(k) : item.label
120
- }
121
-
122
- /* ---------------- 分组折叠 + 分组管理(后端为权威,本地只管展开偏好) ---------------- */
123
- const GROUP_COLLAPSE_KEY = 'aw.settings.groups.collapsed'
124
- /** 本地展开/收起偏好:{ [groupId]: collapsed } */
125
- const groupCollapsed = ref<Record<string, boolean>>({})
126
- function loadCollapsePref(): void {
127
- try {
128
- groupCollapsed.value = JSON.parse(localStorage.getItem(GROUP_COLLAPSE_KEY) ?? '{}') ?? {}
129
- }
130
- catch {
131
- groupCollapsed.value = {}
132
- }
133
- }
134
- function saveCollapsePref(): void {
135
- try {
136
- localStorage.setItem(GROUP_COLLAPSE_KEY, JSON.stringify(groupCollapsed.value))
137
- }
138
- catch { /* 隐私模式等场景忽略 */ }
139
- }
140
- function isCollapsed(g: { id: string, collapsed: boolean }): boolean {
141
- const local = groupCollapsed.value[g.id]
142
- return local === undefined ? g.collapsed : local
143
- }
144
- function toggleGroup(g: { id: string, collapsed: boolean, collapsible: boolean }): void {
145
- if (!g.collapsible) return
146
- groupCollapsed.value = { ...groupCollapsed.value, [g.id]: !isCollapsed(g) }
147
- saveCollapsePref()
148
- }
149
- /** 全部展开/收起(一键;按当前是否已全展开决定方向) */
150
- function toggleAllGroups(): void {
151
- const list = rcStore.groups.filter(g => g.collapsible)
152
- const anyExpanded = list.some(g => !isCollapsed(g))
153
- const next = { ...groupCollapsed.value }
154
- for (const g of list) next[g.id] = anyExpanded
155
- groupCollapsed.value = next
156
- saveCollapsePref()
157
- }
158
- const allExpanded = computed(() => rcStore.groups.filter(g => g.collapsible).some(g => !isCollapsed(g)))
159
-
160
- /* 分组 CRUD(admin):内联表单 + 行内改名,避免引入全局弹窗依赖 */
161
- const isAdmin = computed(() => userStore.isAdmin)
162
- const groupFormOpen = ref(false)
163
- const groupForm = reactive({ label: '', description: '', collapsed: false })
164
- const groupBusy = ref('')
165
- const renamingId = ref('')
166
- const renameDraft = ref('')
167
-
168
- async function submitGroupForm(): Promise<void> {
169
- if (!groupForm.label.trim()) return
170
- groupBusy.value = 'create'
171
- try {
172
- await rcStore.createGroup({ label: groupForm.label.trim(), description: groupForm.description.trim(), collapsed: groupForm.collapsed })
173
- groupFormOpen.value = false
174
- groupForm.label = ''
175
- groupForm.description = ''
176
- groupForm.collapsed = false
177
- message.success(t('settings.groups.created'))
178
- }
179
- catch (e) {
180
- message.error(apiErrorMessage(e, t('settings.groups.createFail')))
181
- }
182
- finally {
183
- groupBusy.value = ''
184
- }
185
- }
186
-
187
- function startRename(g: { id: string, label: string }): void {
188
- renamingId.value = g.id
189
- renameDraft.value = g.label
190
- }
191
- async function commitRename(id: string): Promise<void> {
192
- const label = renameDraft.value.trim()
193
- renamingId.value = ''
194
- if (!label) return
195
- groupBusy.value = id
196
- try {
197
- await rcStore.updateGroup(id, { label })
198
- message.success(t('settings.groups.renamed'))
199
- }
200
- catch (e) {
201
- message.error(apiErrorMessage(e, t('settings.groups.updateFail')))
202
- }
203
- finally {
204
- groupBusy.value = ''
205
- }
206
- }
207
-
208
- /** 排序:与相邻同源分组交换 order(通过各自的 order 值互换实现稳定重排) */
209
- async function moveGroup(id: string, dir: -1 | 1): Promise<void> {
210
- const list = [...rcStore.groups]
211
- const i = list.findIndex(g => g.id === id)
212
- const j = i + dir
213
- if (i < 0 || j < 0 || j >= list.length) return
214
- const a = list[i]!
215
- const b = list[j]!
216
- groupBusy.value = id
217
- try {
218
- // 顺序值可能相等(插件组同 order)→ 用「重排后的显式序号」写回,保证结果确定
219
- const ordered = [...list]
220
- ordered.splice(i, 1)
221
- ordered.splice(j, 0, a)
222
- const patchA = (j + 1) * 10
223
- const patchB = (i + 1) * 10
224
- await rcStore.updateGroup(a.id, { order: patchA })
225
- await rcStore.updateGroup(b.id, { order: patchB })
226
- void b
227
- }
228
- catch (e) {
229
- message.error(apiErrorMessage(e, t('settings.groups.updateFail')))
230
- }
231
- finally {
232
- groupBusy.value = ''
233
- }
234
- }
235
-
236
- async function setGroupDefaultCollapsed(g: { id: string, collapsed: boolean }): Promise<void> {
237
- groupBusy.value = g.id
238
- try {
239
- await rcStore.updateGroup(g.id, { collapsed: !g.collapsed })
240
- }
241
- catch (e) {
242
- message.error(apiErrorMessage(e, t('settings.groups.updateFail')))
243
- }
244
- finally {
245
- groupBusy.value = ''
246
- }
247
- }
248
-
249
- async function removeGroup(g: { id: string, label: string, fieldCount?: number }): Promise<void> {
250
- const count = g.fieldCount ?? 0
251
- if (count > 0) {
252
- message.warning(t('settings.groups.deleteHasFields', { p0: groupLabel(g), p1: count }))
253
- return
254
- }
255
- groupBusy.value = g.id
256
- try {
257
- await rcStore.deleteGroup(g.id)
258
- message.success(t('settings.groups.deleted'))
259
- }
260
- catch (e) {
261
- message.error(apiErrorMessage(e, t('settings.groups.deleteFail')))
262
- }
263
- finally {
264
- groupBusy.value = ''
265
- }
266
- }
267
- /* ============================================================= */
268
-
269
- /* ================= 插件管理(清单/启停/健康检测) ================= */
270
- interface PluginHealth {
271
- ok: boolean
272
- reason?: string
273
- }
274
-
275
- const plugins = ref<WorkshopPluginDto[]>([])
276
- const pluginsLoading = ref(false)
277
- const pluginsLoaded = ref(false)
278
- const togglingPlugins = ref<Set<string>>(new Set())
279
- const healthMap = ref<Record<string, PluginHealth | 'checking'>>({})
280
-
281
- const hasHealthRoute = (p: WorkshopPluginDto): boolean =>
282
- (p.routes ?? []).some(r => String(r.path || '').endsWith('/health'))
283
-
284
- async function loadPlugins() {
285
- pluginsLoading.value = true
286
- try {
287
- const res = await api.listPlugins()
288
- // 顶层 plugins key(非信封);兼容 {code,data} 信封
289
- const list = res?.plugins ?? (res as { data?: { plugins?: WorkshopPluginDto[] } })?.data?.plugins ?? []
290
- plugins.value = Array.isArray(list) ? list : []
291
- pluginsLoaded.value = true
292
- }
293
- catch (e) {
294
- message.error(apiErrorMessage(e, t('plugins.loadFail')))
295
- }
296
- finally {
297
- pluginsLoading.value = false
298
- }
299
- }
300
-
301
- async function togglePlugin(p: WorkshopPluginDto, next: boolean) {
302
- if (togglingPlugins.value.has(p.name)) return
303
- togglingPlugins.value.add(p.name)
304
- try {
305
- if (next) await api.enablePlugin(p.name)
306
- else await api.disablePlugin(p.name)
307
- p.enabled = next
308
- message.success(next ? t('plugins.enabled') : t('plugins.disabled'))
309
- }
310
- catch (e) {
311
- // 普通用户 403 → 需要管理员权限
312
- message.error(apiErrorMessage(e, t('plugins.adminRequired')))
313
- }
314
- finally {
315
- togglingPlugins.value.delete(p.name)
316
- }
317
- }
318
-
319
- async function checkPluginHealth(p: WorkshopPluginDto) {
320
- if (!hasHealthRoute(p)) return
321
- healthMap.value = { ...healthMap.value, [p.name]: 'checking' }
322
- try {
323
- const res = await api.pluginHealth(p.name)
324
- healthMap.value = { ...healthMap.value, [p.name]: judgePluginHealth(p.name, res) }
325
- }
326
- catch (e) {
327
- healthMap.value = { ...healthMap.value, [p.name]: { ok: false, reason: apiErrorMessage(e, t('plugins.loadFail')) } }
328
- }
329
- }
330
-
331
- /** 健康判定:rag-bridge 看 backend.ok && web.ok;diag-bridge 看 remote.status==='ok';未知形状回退无报错即视为正常 */
332
- function judgePluginHealth(name: string, res: Record<string, unknown>): PluginHealth {
333
- if (name === 'rag-bridge') {
334
- const backend = res.backend as { ok?: boolean, error?: string, status?: string } | undefined
335
- const web = res.web as { ok?: boolean, error?: string, status?: string } | undefined
336
- const ok = Boolean(backend?.ok) && Boolean(web?.ok)
337
- const reason = !backend?.ok
338
- ? (backend?.error || backend?.status || 'backend')
339
- : (web?.error || web?.status || 'web')
340
- return ok ? { ok: true } : { ok: false, reason }
341
- }
342
- if (name === 'diag-bridge') {
343
- const remote = res.remote as { status?: string } | undefined
344
- const ok = remote?.status === 'ok'
345
- return ok ? { ok: true } : { ok: false, reason: remote?.status || 'remote' }
346
- }
347
- return { ok: true }
348
- }
349
-
350
- /** 模板辅助:检测中 / 检测结果(避免模板内窄化) */
351
- function isCheckingHealth(name: string): boolean {
352
- return healthMap.value[name] === 'checking'
353
- }
354
- function healthOf(name: string): PluginHealth | null {
355
- const h = healthMap.value[name]
356
- return (h && h !== 'checking') ? h : null
357
- }
358
- function isToggling(name: string): boolean {
359
- return togglingPlugins.value.has(name)
360
- }
361
-
362
- // 进入运行配置 Tab 时懒加载插件清单(端点需登录)
363
- watch(activeTab, (v) => {
364
- if (v === 'runtime' && !pluginsLoaded.value && !pluginsLoading.value && userStore.isLoggedIn) void loadPlugins()
365
- }, { immediate: true })
366
-
367
- // 折叠偏好只在浏览器侧读写(localStorage);SSR 首帧用服务端默认折叠态,水合后无缝接管
368
- onMounted(loadCollapsePref)
369
- /* ============================================================= */
370
-
371
- /** 强调色预设(控制室低饱和族;默认 = 品牌绿,跟随 config.yml) */
372
- const defaultAccent = String(config.primaryColor)
373
- const colorPresets = [
374
- { value: defaultAccent, label: t('settings.accentInk') },
375
- { value: '#c9963f', label: t('settings.accentStone') },
376
- { value: '#44615a', label: t('settings.accentMoss') },
377
- { value: '#3f6094', label: t('settings.accentSlate') },
378
- { value: '#6b5aa0', label: t('settings.accentPlum') },
379
- { value: '#9c5744', label: t('settings.accentClay') },
380
- ]
381
-
382
- const currentAccent = computed(() => store.accent ?? defaultAccent)
383
-
384
- function pickAccent(c: string) {
385
- store.setAccent(c === defaultAccent ? null : c)
386
- message.success(t('settings.accentApplied'))
387
- }
388
-
389
- /** 恢复默认(品牌绿,跟随 config.yml) */
390
- function resetAccent() {
391
- store.setAccent(null)
392
- message.success(t('settings.accentReset'))
393
- }
394
-
395
- const localeOptions = computed(() =>
396
- (locales.value as Array<{ code: string, name: string }>).map(l => ({
397
- label: l.name,
398
- value: l.code,
399
- })),
400
- )
401
-
402
- const switchLocale = (value: unknown) => {
403
- if (value != null) {
404
- // 持久化 + 强刷:与 AppHeader 同策略(setup 期词条需重载整体切换)
405
- localStorage.setItem('aw.locale', String(value))
406
- setLocale(String(value) as 'zh-CN' | 'en')
407
- window.location.reload()
408
- }
409
- }
410
-
411
- /** 系统态概览(只读,同源 config.yml) */
412
- const systemRows = computed(() => [
413
- { label: t('home.fields.name'), value: site.name },
414
- { label: t('home.fields.version'), value: `v${site.version}` },
415
- { label: t('home.fields.mode'), value: site.mode },
416
- { label: t('home.fields.apiBase'), value: `${site.apiBase} (${config.apiTimeout}ms)` },
417
- { label: t('home.fields.primary'), value: defaultAccent },
418
- { label: 'i18n', value: locale.value },
419
- ])
420
-
421
- const tabs = computed(() => [
422
- { key: 'appearance', icon: 'i-tabler-palette', label: t('settings.theme') },
423
- { key: 'runtime', icon: 'i-tabler-settings', label: t('settings.runtimeTab') },
424
- { key: 'system', icon: 'i-tabler-server-2', label: t('settings.systemTab') },
425
- ])
426
- </script>
427
-
428
- <template>
429
- <div class="settings-page">
430
- <div class="aw-page-head">
431
- <div>
432
- <p class="aw-kicker">
433
- {{ t('menu.system') }} / settings
434
- </p>
435
- <h1>{{ t('settings.title') }}</h1>
436
- </div>
437
- <span class="aw-stamp">v{{ site.version }}</span>
438
- </div>
439
-
440
- <a-card
441
- :bordered="false"
442
- class="settings-card"
443
- >
444
- <div class="settings-layout">
445
- <!-- 左侧 Tab 导航 -->
446
- <div class="settings-nav">
447
- <button
448
- v-for="tab in tabs"
449
- :key="tab.key"
450
- class="nav-item"
451
- :class="{ active: activeTab === tab.key }"
452
- @click="activeTab = tab.key"
453
- >
454
- <span :class="tab.icon" />
455
- <span>{{ tab.label }}</span>
456
- </button>
457
- </div>
458
-
459
- <!-- 右侧内容区 -->
460
- <div class="settings-body">
461
- <!-- 外观:主题色 / 深浅 / 语言(全部实时生效并持久化) -->
462
- <div v-show="activeTab === 'appearance'">
463
- <h3 class="section-title">
464
- {{ t('settings.theme') }}
465
- </h3>
466
- <p class="section-desc">
467
- {{ t('settings.appearanceDesc') }}
468
- </p>
469
-
470
- <div class="set-row">
471
- <div class="set-text">
472
- <div class="set-title">
473
- {{ t('settings.primaryColor') }}
474
- </div>
475
- <div class="set-sub">
476
- {{ t('settings.accentDesc') }}
477
- </div>
478
- </div>
479
- </div>
480
- <div class="color-swatches">
481
- <button
482
- v-for="c in colorPresets"
483
- :key="c.value"
484
- class="swatch"
485
- :class="{ on: currentAccent.toLowerCase() === c.value.toLowerCase() }"
486
- :style="{ '--sw': c.value }"
487
- :title="c.label"
488
- @click="pickAccent(c.value)"
489
- >
490
- <span class="swatch-dot" />
491
- <span class="swatch-label">{{ c.label }}</span>
492
- <span
493
- v-if="currentAccent.toLowerCase() === c.value.toLowerCase()"
494
- class="i-tabler-check swatch-check"
495
- />
496
- </button>
497
- </div>
498
- <button
499
- class="aw-pill outline reset-btn"
500
- @click="resetAccent"
501
- >
502
- <span class="i-tabler-rotate" />
503
- {{ t('settings.accentResetAction') }}
504
- </button>
505
-
506
- <div class="toggle-row">
507
- <div class="set-text">
508
- <div class="set-title">
509
- {{ store.isDark ? t('common.dark') : t('common.light') }}
510
- </div>
511
- <div class="set-sub">
512
- {{ t('settings.darkDesc') }}
513
- </div>
514
- </div>
515
- <button
516
- class="switch"
517
- :class="{ on: store.isDark }"
518
- role="switch"
519
- :aria-checked="store.isDark"
520
- @click="store.toggleDark()"
521
- >
522
- <span class="knob" />
523
- </button>
524
- </div>
525
-
526
- <div class="toggle-row">
527
- <div class="set-text">
528
- <div class="set-title">
529
- {{ t('header.language') }}
530
- </div>
531
- <div class="set-sub">
532
- {{ t('settings.languageDesc') }}
533
- </div>
534
- </div>
535
- <a-select
536
- :value="locale"
537
- :options="localeOptions"
538
- style="width: 140px"
539
- @change="switchLocale"
540
- />
541
- </div>
542
- </div>
543
-
544
- <!-- 运行配置:服务端持久化 + live 热重载/restart 重启生效 -->
545
- <div v-show="activeTab === 'runtime'">
546
- <h3 class="section-title">
547
- {{ t('settings.runtimeTab') }}
548
- </h3>
549
- <p class="section-desc">
550
- {{ t('settings.runtime.desc') }}
551
- </p>
552
-
553
- <a-alert
554
- v-if="runtimeNotice"
555
- :type="runtimeNotice.type"
556
- show-icon
557
- class="rt-notice"
558
- :message="runtimeNotice.text"
559
- />
560
-
561
- <!-- 分组工具条:一键展开/收起 + (admin) 新建分组 -->
562
- <div class="rt-groups-bar">
563
- <button
564
- class="aw-pill outline rt-groups-toggle"
565
- :title="t('settings.groups.toggleAll')"
566
- @click="toggleAllGroups"
567
- >
568
- <span :class="allExpanded ? 'i-tabler-chevrons-up' : 'i-tabler-chevrons-down'" />
569
- {{ allExpanded ? t('settings.groups.collapseAll') : t('settings.groups.expandAll') }}
570
- </button>
571
- <span class="rt-groups-count">{{ t('settings.groups.count', { p0: rcStore.groups.length }) }}</span>
572
- <button
573
- v-if="isAdmin"
574
- class="aw-pill outline rt-groups-add"
575
- @click="groupFormOpen = !groupFormOpen"
576
- >
577
- <span class="i-tabler-plus" />
578
- {{ t('settings.groups.newGroup') }}
579
- </button>
580
- </div>
581
-
582
- <!-- 新建分组(内联表单;id 由服务端按 label 派生,创建后即可用 API 归属字段) -->
583
- <div
584
- v-if="isAdmin && groupFormOpen"
585
- class="rt-group-form"
586
- >
587
- <a-input
588
- v-model:value="groupForm.label"
589
- :placeholder="t('settings.groups.namePlaceholder')"
590
- style="width: 220px"
591
- @press-enter="submitGroupForm"
592
- />
593
- <a-input
594
- v-model:value="groupForm.description"
595
- :placeholder="t('settings.groups.descPlaceholder')"
596
- style="width: 320px"
597
- @press-enter="submitGroupForm"
598
- />
599
- <a-checkbox v-model:checked="groupForm.collapsed">
600
- {{ t('settings.groups.defaultCollapsed') }}
601
- </a-checkbox>
602
- <button
603
- class="aw-pill primary"
604
- :disabled="!groupForm.label.trim() || groupBusy === 'create'"
605
- @click="submitGroupForm"
606
- >
607
- {{ groupBusy === 'create' ? '…' : t('settings.groups.create') }}
608
- </button>
609
- <button
610
- class="aw-pill outline"
611
- @click="groupFormOpen = false"
612
- >
613
- {{ t('settings.groups.cancel') }}
614
- </button>
615
- </div>
616
-
617
- <!-- 分组分区:顺序/标题/折叠态全部来自后端 groups 接口 -->
618
- <section
619
- v-for="g in rcStore.groups"
620
- :key="g.id"
621
- class="rt-group"
622
- :class="{ 'collapsed': isCollapsed(g), 'grp-plugin': g.source === 'plugin' }"
623
- >
624
- <header
625
- class="rt-group-head"
626
- :role="g.collapsible ? 'button' : undefined"
627
- :tabindex="g.collapsible ? 0 : undefined"
628
- @click="toggleGroup(g)"
629
- @keydown.enter.prevent="toggleGroup(g)"
630
- @keydown.space.prevent="toggleGroup(g)"
631
- >
632
- <span
633
- v-if="g.collapsible"
634
- class="rt-caret"
635
- :class="isCollapsed(g) ? 'i-tabler-chevron-right' : 'i-tabler-chevron-down'"
636
- />
637
- <span
638
- v-if="g.icon"
639
- class="rt-group-icon"
640
- :class="g.icon"
641
- />
642
- <template v-if="renamingId === g.id">
643
- <input
644
- v-model="renameDraft"
645
- class="rt-rename aw-mono"
646
- @click.stop
647
- @keydown.enter.prevent="commitRename(g.id)"
648
- @keydown.esc.prevent="renamingId = ''"
649
- >
650
- <button
651
- class="mini-btn"
652
- @click.stop="commitRename(g.id)"
653
- >
654
- {{ t('settings.groups.ok') }}
655
- </button>
656
- </template>
657
- <h4
658
- v-else
659
- class="rt-group-title"
660
- >
661
- {{ groupLabel(g) }}
662
- </h4>
663
- <span class="rt-group-badge">{{ groupSourceBadge(g) }}</span>
664
- <span class="rt-group-fields">{{ t('settings.groups.fields', { p0: g.fieldCount ?? 0 }) }}</span>
665
- <span
666
- v-if="g.description"
667
- class="rt-group-desc"
668
- >{{ g.description }}</span>
669
- <!-- 分组操作(admin):插件声明的分组由插件权威,不提供改名/删除 -->
670
- <span
671
- v-if="isAdmin && g.source !== 'plugin'"
672
- class="rt-group-ops"
673
- @click.stop
674
- >
675
- <button
676
- class="mini-btn"
677
- :disabled="groupBusy === g.id"
678
- :title="t('settings.groups.moveUp')"
679
- @click="moveGroup(g.id, -1)"
680
- >
681
- <span class="i-tabler-arrow-up" />
682
- </button>
683
- <button
684
- class="mini-btn"
685
- :disabled="groupBusy === g.id"
686
- :title="t('settings.groups.moveDown')"
687
- @click="moveGroup(g.id, 1)"
688
- >
689
- <span class="i-tabler-arrow-down" />
690
- </button>
691
- <button
692
- class="mini-btn"
693
- :disabled="groupBusy === g.id"
694
- :title="t('settings.groups.rename')"
695
- @click="startRename(g)"
696
- >
697
- <span class="i-tabler-pencil" />
698
- </button>
699
- <button
700
- class="mini-btn"
701
- :disabled="groupBusy === g.id"
702
- :title="t('settings.groups.defaultCollapsed')"
703
- @click="setGroupDefaultCollapsed(g)"
704
- >
705
- <span :class="g.collapsed ? 'i-tabler-chevrons-down' : 'i-tabler-chevrons-up'" />
706
- </button>
707
- <button
708
- v-if="g.source === 'user'"
709
- class="mini-btn"
710
- :disabled="groupBusy === g.id"
711
- :title="t('settings.groups.delete')"
712
- @click="removeGroup(g)"
713
- >
714
- <span class="i-tabler-trash" />
715
- </button>
716
- </span>
717
- </header>
718
-
719
- <div
720
- v-show="!isCollapsed(g)"
721
- class="rt-group-body"
722
- >
723
- <p
724
- v-if="!(g.fieldCount ?? 0)"
725
- class="rt-group-empty"
726
- >
727
- {{ t('settings.groups.emptyHint') }}
728
- </p>
729
- <div
730
- v-for="item in rcStore.fieldsOf(g.id)"
731
- :key="item.key"
732
- class="rt-row"
733
- >
734
- <div class="rt-main">
735
- <div class="rt-title">
736
- {{ itemLabel(item) }}
737
- <span
738
- class="rt-tag"
739
- :class="sourceClass(rcStore.sourceOf(item.key))"
740
- >{{ rcStore.sourceOf(item.key) }}</span>
741
- <span
742
- class="rt-tag"
743
- :class="item.applies"
744
- >{{ item.applies === 'live' ? t('settings.runtime.live') : t('settings.runtime.restart') }}</span>
745
- </div>
746
- <div class="rt-sub">
747
- {{ item.description }}
748
- </div>
749
- </div>
750
- <div class="rt-ctrl">
751
- <a-input-number
752
- v-if="item.type === 'number'"
753
- v-model:value="(draft[item.key] as string | number | undefined)"
754
- :min="item.min"
755
- :max="item.max"
756
- @change="markDirty(item.key)"
757
- />
758
- <a-switch
759
- v-else-if="item.type === 'boolean'"
760
- v-model:checked="(draft[item.key] as string | number | boolean | undefined)"
761
- @change="markDirty(item.key)"
762
- />
763
- <a-select
764
- v-else-if="item.type === 'select'"
765
- v-model:value="(draft[item.key] as string | number | undefined)"
766
- style="width: 160px"
767
- :options="(item.options ?? []).map(o => ({ label: o, value: o }))"
768
- @change="markDirty(item.key)"
769
- />
770
- <span
771
- v-else-if="item.type === 'color'"
772
- class="rt-color"
773
- >
774
- <input
775
- type="color"
776
- :value="String(draft[item.key] ?? '#35e0a0')"
777
- @input="draft[item.key] = ($event.target as HTMLInputElement).value; markDirty(item.key)"
778
- >
779
- <a-input
780
- :value="String(draft[item.key] ?? '')"
781
- style="width: 110px"
782
- class="aw-mono"
783
- @change="draft[item.key] = ($event.target as HTMLInputElement).value; markDirty(item.key)"
784
- />
785
- </span>
786
- <a-input
787
- v-else
788
- v-model:value="(draft[item.key] as string | number | undefined)"
789
- style="width: 220px"
790
- @change="markDirty(item.key)"
791
- />
792
- <button
793
- class="aw-pill outline rt-reset"
794
- :title="t('settings.runtime.resetKey')"
795
- @click="resetRuntimeKey(item.key)"
796
- >
797
- <span class="i-tabler-rotate" />
798
- </button>
799
- </div>
800
- </div>
801
- </div>
802
- </section>
803
-
804
- <div class="rt-actions">
805
- <button
806
- class="aw-pill primary"
807
- :disabled="!dirtyKeys.size || savingRuntime"
808
- @click="saveRuntime"
809
- >
810
- <span class="i-tabler-device-floppy" />
811
- {{ savingRuntime ? '…' : t('settings.runtime.save') }}
812
- </button>
813
- <button
814
- class="aw-pill outline"
815
- @click="resetAllRuntime"
816
- >
817
- {{ t('settings.runtime.resetAll') }}
818
- </button>
819
- <span class="rt-path aw-mono">{{ rcStore.settingsPath }}</span>
820
- </div>
821
-
822
- <!-- 插件管理:清单 / 启停(admin) / 健康检测 -->
823
- <h4 class="rt-group-title plugin-group-title">
824
- {{ t('plugins.title') }}
825
- </h4>
826
- <p class="section-desc">
827
- {{ t('plugins.desc') }}
828
- </p>
829
-
830
- <div
831
- v-if="!userStore.isLoggedIn"
832
- class="identity-note"
833
- >
834
- {{ t('plugins.needLogin') }}
835
- </div>
836
- <a-spin
837
- v-else
838
- :spinning="pluginsLoading"
839
- >
840
- <div
841
- v-for="p in plugins"
842
- :key="p.name"
843
- class="plugin-row"
844
- :class="{ off: p.enabled === false }"
845
- >
846
- <div class="plugin-main">
847
- <div class="rt-title">
848
- <span class="aw-mono">{{ p.name }}</span>
849
- <span
850
- v-if="p.version"
851
- class="rt-tag"
852
- >v{{ p.version }}</span>
853
- <span
854
- v-if="p.builtin"
855
- class="rt-tag src-runtime"
856
- >{{ t('plugins.builtin') }}</span>
857
- <span
858
- v-if="p.error"
859
- class="rt-tag restart"
860
- >{{ p.error }}</span>
861
- </div>
862
- <div class="rt-sub">
863
- {{ p.description || '—' }}
864
- </div>
865
- </div>
866
- <div class="rt-ctrl">
867
- <span
868
- v-if="isCheckingHealth(p.name)"
869
- class="plugin-health faint"
870
- >
871
- <span class="i-tabler-loader-2 spin" />
872
- {{ t('plugins.healthChecking') }}
873
- </span>
874
- <span
875
- v-else-if="healthOf(p.name)"
876
- class="plugin-health"
877
- :class="healthOf(p.name)!.ok ? 'ok' : 'bad'"
878
- >
879
- <span :class="healthOf(p.name)!.ok ? 'i-tabler-circle-check' : 'i-tabler-alert-circle'" />
880
- {{ healthOf(p.name)!.ok ? t('plugins.healthOk') : `${t('plugins.healthBad')}: ${healthOf(p.name)!.reason ?? ''}` }}
881
- </span>
882
- <button
883
- v-if="hasHealthRoute(p)"
884
- class="aw-pill outline rt-reset"
885
- @click="checkPluginHealth(p)"
886
- >
887
- <span class="i-tabler-heartbeat" />
888
- {{ t('plugins.healthCheck') }}
889
- </button>
890
- <button
891
- class="switch"
892
- :class="{ on: p.enabled !== false }"
893
- role="switch"
894
- :aria-checked="p.enabled !== false"
895
- :disabled="isToggling(p.name)"
896
- @click="togglePlugin(p, p.enabled === false)"
897
- >
898
- <span class="knob" />
899
- </button>
900
- </div>
901
- </div>
902
- <div
903
- v-if="plugins.length === 0 && !pluginsLoading"
904
- class="identity-note"
905
- >
906
- {{ t('plugins.empty') }}
907
- </div>
908
- </a-spin>
909
-
910
- <!-- 插件 UI 注入区:client 面板经 ctx.ui.registerPanel({slot:'settings.plugins'}) 注入 -->
911
- <workshop-plugin-slot
912
- v-if="plugins.length"
913
- slot-name="settings.plugins"
914
- />
915
- </div>
916
-
917
- <!-- 系统:只读运行参数 -->
918
- <div v-show="activeTab === 'system'">
919
- <h3 class="section-title">
920
- {{ t('settings.systemTab') }}
921
- </h3>
922
- <p class="section-desc">
923
- {{ t('settings.systemDesc') }}
924
- </p>
925
- <a-descriptions
926
- bordered
927
- :column="1"
928
- size="small"
929
- >
930
- <a-descriptions-item
931
- v-for="row in systemRows"
932
- :key="row.label"
933
- :label="row.label"
934
- >
935
- <span class="aw-mono">{{ row.value }}</span>
936
- </a-descriptions-item>
937
- </a-descriptions>
938
- <p class="identity-note">
939
- {{ userStore.isLoggedIn
940
- ? `${t('settings.identity')}: ${userStore.user?.name} (${userStore.user?.role})`
941
- : t('settings.identityGuest') }}
942
- </p>
943
- </div>
944
- </div>
945
- </div>
946
- </a-card>
947
- </div>
948
- </template>
949
-
950
- <style scoped>
951
- .settings-page {
952
- padding: 4px;
953
- }
954
-
955
- .settings-card {
956
- overflow: hidden;
957
- }
958
-
959
- .settings-layout {
960
- display: flex;
961
- min-height: 420px;
962
- }
963
-
964
- /* 左侧导航:8px 圆角条目(active = surface-strong 墨字) */
965
- .settings-nav {
966
- display: flex;
967
- flex: 0 0 180px;
968
- flex-direction: column;
969
- gap: 2px;
970
- padding: 14px;
971
- border-right: 1px solid var(--line);
972
- }
973
-
974
- .nav-item {
975
- display: flex;
976
- gap: 9px;
977
- align-items: center;
978
- width: 100%;
979
- padding: 8px 10px;
980
- font-family: var(--font-body);
981
- font-size: 13px;
982
- font-weight: 500;
983
- color: var(--ink-faint);
984
- text-align: left;
985
- cursor: pointer;
986
- background: transparent;
987
- border: 0;
988
- border-radius: var(--radius-panel-sm);
989
- transition: background var(--transition-fast), color var(--transition-fast);
990
- }
991
-
992
- .nav-item:hover {
993
- color: var(--ink);
994
- background: var(--paper-deep);
995
- }
996
-
997
- .nav-item.active {
998
- font-weight: 600;
999
- color: var(--ink);
1000
- background: var(--paper-deep);
1001
- }
1002
-
1003
- .settings-body {
1004
- flex: 1;
1005
- min-width: 0;
1006
- padding: 22px 26px;
1007
- }
1008
-
1009
- .section-title {
1010
- margin: 0 0 4px;
1011
- font-family: var(--font-display);
1012
- font-size: 22px;
1013
- font-weight: 400;
1014
- letter-spacing: -0.01em;
1015
- color: var(--ink);
1016
- }
1017
-
1018
- .section-desc {
1019
- margin: 0 0 18px;
1020
- font-size: 13px;
1021
- line-height: 1.6;
1022
- color: var(--ink-faint);
1023
- }
1024
-
1025
- /* 色板:粉彩圆点 + 名称;选中 = 墨描边 */
1026
- .color-swatches {
1027
- display: flex;
1028
- flex-wrap: wrap;
1029
- gap: 10px;
1030
- margin: 12px 0 4px;
1031
- }
1032
-
1033
- .swatch {
1034
- position: relative;
1035
- display: inline-flex;
1036
- gap: 8px;
1037
- align-items: center;
1038
- padding: 8px 14px;
1039
- font-family: var(--font-body);
1040
- font-size: 13px;
1041
- font-weight: 500;
1042
- color: var(--ink-soft);
1043
- cursor: pointer;
1044
- background: var(--paper-raised);
1045
- border: 1px solid var(--line-strong);
1046
- border-radius: var(--radius-pill);
1047
- transition: border-color var(--transition-fast), background var(--transition-fast);
1048
- }
1049
-
1050
- .swatch:hover {
1051
- background: var(--paper-deep);
1052
- }
1053
-
1054
- .swatch.on {
1055
- color: var(--ink);
1056
- border-color: var(--ink);
1057
- }
1058
-
1059
- .swatch-dot {
1060
- width: 14px;
1061
- height: 14px;
1062
- background: var(--sw);
1063
- border: 1px solid rgb(0 0 0 / 8%);
1064
- border-radius: 50%;
1065
- }
1066
-
1067
- .swatch-check {
1068
- font-size: 13px;
1069
- color: var(--ink);
1070
- }
1071
-
1072
- .reset-btn {
1073
- margin: 10px 0 4px;
1074
- }
1075
-
1076
- /* 开关行(设置页 toggle row) */
1077
- .toggle-row,
1078
- .set-row {
1079
- display: flex;
1080
- align-items: center;
1081
- justify-content: space-between;
1082
- gap: 20px;
1083
- padding: 18px 0;
1084
- border-bottom: 1px solid var(--line);
1085
- }
1086
-
1087
- .set-text {
1088
- min-width: 0;
1089
- }
1090
-
1091
- .set-title {
1092
- font-size: 14px;
1093
- font-weight: 500;
1094
- color: var(--ink-soft);
1095
- }
1096
-
1097
- .set-sub {
1098
- max-width: 52ch;
1099
- margin-top: 3px;
1100
- font-size: 13px;
1101
- line-height: 1.5;
1102
- color: var(--ink-faint);
1103
- }
1104
-
1105
- /* 药丸开关(ink on 态) */
1106
- .switch {
1107
- position: relative;
1108
- flex: none;
1109
- width: 42px;
1110
- height: 25px;
1111
- padding: 0;
1112
- cursor: pointer;
1113
- background: var(--line-strong);
1114
- border: 0;
1115
- border-radius: var(--radius-pill);
1116
- transition: background 0.18s cubic-bezier(0.22, 1, 0.36, 1);
1117
- }
1118
-
1119
- .switch .knob {
1120
- position: absolute;
1121
- top: 3px;
1122
- left: 3px;
1123
- width: 19px;
1124
- height: 19px;
1125
- background: var(--paper-raised);
1126
- border-radius: 50%;
1127
- box-shadow: 0 1px 2px rgb(12 10 9 / 25%);
1128
- transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
1129
- }
1130
-
1131
- .switch.on {
1132
- background: var(--accent);
1133
- }
1134
-
1135
- .switch.on .knob {
1136
- transform: translateX(17px);
1137
- }
1138
-
1139
- @media (prefers-reduced-motion: reduce) {
1140
- .switch,
1141
- .switch .knob {
1142
- transition: none;
1143
- }
1144
- }
1145
-
1146
- .identity-note {
1147
- margin: 14px 0 0;
1148
- font-size: 12.5px;
1149
- color: var(--ink-faint);
1150
- }
1151
-
1152
- /* ============ 运行配置标签 ============ */
1153
- .rt-notice {
1154
- margin: 0 0 14px;
1155
- }
1156
-
1157
- /* ── 分组分区(后端 groups 接口驱动:顺序/标题/折叠态均来自服务端) ── */
1158
- .rt-groups-bar {
1159
- display: flex;
1160
- gap: 8px;
1161
- align-items: center;
1162
- margin: 12px 0 10px;
1163
- }
1164
-
1165
- .rt-groups-count {
1166
- font-size: 11.5px;
1167
- color: var(--ink-faint);
1168
- }
1169
-
1170
- .rt-groups-add {
1171
- margin-left: auto;
1172
- }
1173
-
1174
- .rt-group-form {
1175
- display: flex;
1176
- flex-wrap: wrap;
1177
- gap: 8px;
1178
- align-items: center;
1179
- padding: 10px 12px;
1180
- margin-bottom: 12px;
1181
- background: var(--frost-bg);
1182
- border: 1px solid var(--glass-line);
1183
- border-radius: 10px;
1184
- }
1185
-
1186
- .rt-group {
1187
- margin: 0 0 10px;
1188
- background: var(--surface-glass);
1189
- border: 1px solid var(--glass-line);
1190
- border-radius: 10px;
1191
- }
1192
-
1193
- /* 插件声明的分区:左侧一道强调色,与平台内置分区一眼可分 */
1194
- .rt-group.grp-plugin {
1195
- border-left: 3px solid color-mix(in srgb, var(--tone-info-dot) 55%, transparent);
1196
- }
1197
-
1198
- .rt-group-head {
1199
- display: flex;
1200
- flex-wrap: wrap;
1201
- gap: 8px;
1202
- align-items: center;
1203
- padding: 10px 14px;
1204
- border-radius: 10px;
1205
- }
1206
-
1207
- .rt-group.collapsed .rt-group-head {
1208
- border-radius: 10px;
1209
- }
1210
-
1211
- .rt-group-head[role='button'] {
1212
- cursor: pointer;
1213
- }
1214
-
1215
- .rt-group-head[role='button']:hover {
1216
- background: var(--hover-tint);
1217
- }
1218
-
1219
- .rt-caret {
1220
- flex: none;
1221
- font-size: 15px;
1222
- color: var(--ink-faint);
1223
- }
1224
-
1225
- .rt-group-icon {
1226
- flex: none;
1227
- font-size: 14px;
1228
- color: var(--accent);
1229
- }
1230
-
1231
- .rt-rename {
1232
- width: 200px;
1233
- padding: 2px 8px;
1234
- font-size: 14px;
1235
- color: var(--ink);
1236
- background: var(--paper-deep);
1237
- border: 1px solid var(--accent);
1238
- border-radius: var(--radius-chip);
1239
- }
1240
-
1241
- .rt-group-badge {
1242
- flex: none;
1243
- padding: 1px 7px;
1244
- font-size: 11.5px;
1245
- color: var(--ink-faint);
1246
- border: 1px solid var(--glass-line);
1247
- border-radius: 99px;
1248
- }
1249
-
1250
- .rt-group.grp-plugin .rt-group-badge {
1251
- color: var(--tone-info-dot);
1252
- border-color: color-mix(in srgb, var(--tone-info-dot) 40%, transparent);
1253
- }
1254
-
1255
- .rt-group-fields {
1256
- flex: none;
1257
- font-size: 11.5px;
1258
- color: var(--ink-faint);
1259
- }
1260
-
1261
- .rt-group-desc {
1262
- min-width: 0;
1263
- font-size: 11.5px;
1264
- color: var(--ink-faint);
1265
- overflow: hidden;
1266
- text-overflow: ellipsis;
1267
- white-space: nowrap;
1268
- }
1269
-
1270
- .rt-group-ops {
1271
- display: flex;
1272
- gap: 2px;
1273
- align-items: center;
1274
- margin-left: auto;
1275
- opacity: 0;
1276
- transition: opacity 0.15s ease;
1277
- }
1278
-
1279
- .rt-group-head:hover .rt-group-ops,
1280
- .rt-group-ops:focus-within {
1281
- opacity: 1;
1282
- }
1283
-
1284
- .rt-group-body {
1285
- padding: 0 14px 6px;
1286
- border-top: 1px solid var(--line);
1287
- }
1288
-
1289
- .rt-group-empty {
1290
- margin: 10px 0 4px;
1291
- font-size: 12.5px;
1292
- color: var(--ink-faint);
1293
- }
1294
-
1295
- .rt-group-title {
1296
- margin: 0;
1297
- font-family: var(--font-display);
1298
- font-size: 14.5px;
1299
- font-weight: 600;
1300
- letter-spacing: 0.01em;
1301
- color: var(--ink-soft);
1302
- }
1303
-
1304
- /* 插件管理页的同名标题保留旧观感(独立于分组分区) */
1305
- .rt-group-title.plugin-group-title {
1306
- margin: 20px 0 2px;
1307
- font-size: 15px;
1308
- }
1309
-
1310
- .rt-row {
1311
- display: flex;
1312
- align-items: center;
1313
- justify-content: space-between;
1314
- gap: 18px;
1315
- padding: 11px 0;
1316
- border-bottom: 1px solid var(--line);
1317
- }
1318
-
1319
- .rt-main {
1320
- min-width: 0;
1321
- }
1322
-
1323
- .rt-title {
1324
- display: flex;
1325
- gap: 8px;
1326
- align-items: center;
1327
- font-size: 13.5px;
1328
- font-weight: 500;
1329
- color: var(--ink-soft);
1330
- }
1331
-
1332
- .rt-sub {
1333
- max-width: 46ch;
1334
- margin-top: 3px;
1335
- font-size: 13px;
1336
- line-height: 1.5;
1337
- color: var(--ink-faint);
1338
- }
1339
-
1340
- .rt-tag {
1341
- padding: 1px 7px;
1342
- font-family: var(--font-mono);
1343
- font-size: 11.5px;
1344
- font-weight: 500;
1345
- line-height: 1.5;
1346
- color: var(--ink-faint);
1347
- border: 1px solid var(--line-strong);
1348
- border-radius: var(--radius-pill);
1349
- }
1350
-
1351
- .rt-tag.src-runtime {
1352
- color: var(--accent);
1353
- border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1354
- }
1355
-
1356
- .rt-tag.src-env {
1357
- color: #c9963f;
1358
- border-color: rgb(201 150 63 / 45%);
1359
- }
1360
-
1361
- .rt-tag.live {
1362
- color: var(--accent);
1363
- border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1364
- }
1365
-
1366
- .rt-tag.restart {
1367
- color: #c9963f;
1368
- border-color: rgb(201 150 63 / 45%);
1369
- }
1370
-
1371
- .rt-ctrl {
1372
- display: flex;
1373
- flex: none;
1374
- gap: 8px;
1375
- align-items: center;
1376
- }
1377
-
1378
- .rt-color {
1379
- display: inline-flex;
1380
- gap: 8px;
1381
- align-items: center;
1382
- }
1383
-
1384
- .rt-color input[type='color'] {
1385
- width: 36px;
1386
- height: 32px;
1387
- padding: 2px;
1388
- cursor: pointer;
1389
- background: var(--paper-raised);
1390
- border: 1px solid var(--line-strong);
1391
- border-radius: 6px;
1392
- }
1393
-
1394
- .rt-reset {
1395
- padding: 4px 9px;
1396
- font-size: 12px;
1397
- }
1398
-
1399
- .rt-actions {
1400
- display: flex;
1401
- gap: 10px;
1402
- align-items: center;
1403
- margin-top: 18px;
1404
- }
1405
-
1406
- .rt-path {
1407
- margin-left: auto;
1408
- font-size: 11.5px;
1409
- color: var(--ink-faint);
1410
- }
1411
-
1412
- /* ============ 插件管理 ============ */
1413
- .plugin-group-title {
1414
- margin-top: 30px;
1415
- padding-top: 22px;
1416
- border-top: 1px solid var(--line);
1417
- }
1418
-
1419
- .plugin-row {
1420
- display: flex;
1421
- align-items: center;
1422
- justify-content: space-between;
1423
- gap: 18px;
1424
- padding: 13px 0;
1425
- border-bottom: 1px solid var(--line);
1426
- transition: opacity var(--transition-fast);
1427
- }
1428
-
1429
- .plugin-row.off {
1430
- opacity: 0.55;
1431
- }
1432
-
1433
- .plugin-main {
1434
- min-width: 0;
1435
- }
1436
-
1437
- .plugin-health {
1438
- display: inline-flex;
1439
- gap: 5px;
1440
- align-items: center;
1441
- max-width: 320px;
1442
- font-size: 12.5px;
1443
- line-height: 1.4;
1444
- }
1445
-
1446
- .plugin-health.ok {
1447
- color: #2e9e6b;
1448
- }
1449
-
1450
- .plugin-health.bad {
1451
- color: #c2554a;
1452
- }
1453
-
1454
- .plugin-health.faint {
1455
- color: var(--ink-faint);
1456
- }
1457
-
1458
- .plugin-health .spin {
1459
- animation: plugin-spin 0.9s linear infinite;
1460
- }
1461
-
1462
- @keyframes plugin-spin {
1463
- to { transform: rotate(360deg); }
1464
- }
1465
-
1466
- @media (prefers-reduced-motion: reduce) {
1467
- .plugin-health .spin {
1468
- animation: none;
1469
- }
1470
- }
1471
-
1472
- @media (max-width: 768px) {
1473
- .rt-row,
1474
- .plugin-row {
1475
- flex-direction: column;
1476
- align-items: flex-start;
1477
- gap: 10px;
1478
- }
1479
- .rt-actions {
1480
- flex-wrap: wrap;
1481
- }
1482
- .rt-path {
1483
- margin-left: 0;
1484
- }
1485
- }
1486
-
1487
- /* ══ 窄屏(≤899px,对齐 useResponsive 的 drawer 档)═══════════════════════
1488
- 桌面是"左栏菜单 180px + 右栏内容"的双栏仪表台。窄屏若仍并排,
1489
- 内容区只剩 ~170px,描述文字被压成"一次一个字"的竖排长条(375px 实测)。
1490
- 窄屏改为:菜单在上,收成一条可横扫的标签带;内容在下,占满整宽。
1491
- 注:原先把这段堆叠规则误写在 prefers-reduced-motion 里 ——
1492
- 那样只有"减少动效"的用户才看到正确布局,是反的,已移到宽度断点。 */
1493
- @media (max-width: 899px) {
1494
- .settings-layout {
1495
- flex-direction: column;
1496
- min-height: 0;
1497
- }
1498
-
1499
- .settings-nav {
1500
- flex: none;
1501
- flex-direction: row;
1502
- gap: 6px;
1503
- padding: 8px 10px;
1504
- overflow-x: auto;
1505
- overscroll-behavior-x: contain;
1506
- border-right: 0;
1507
- border-bottom: 1px solid var(--line);
1508
- scrollbar-width: none;
1509
- -webkit-overflow-scrolling: touch;
1510
- }
1511
-
1512
- .settings-nav::-webkit-scrollbar {
1513
- display: none;
1514
- }
1515
-
1516
- .nav-item {
1517
- flex: none;
1518
- width: auto;
1519
- min-height: 40px;
1520
- padding: 8px 14px;
1521
- white-space: nowrap;
1522
- }
1523
-
1524
- .settings-body {
1525
- padding: 16px 12px;
1526
- }
1527
-
1528
- /* 卡片内边距在窄屏收边,把宽度让给内容 */
1529
- .settings-card :deep(.ant-card-body) {
1530
- padding: 12px;
1531
- }
1532
-
1533
- /* 正文说明文字窄屏抬到 13px 地板 */
1534
- .set-sub,
1535
- .rt-sub,
1536
- .section-desc,
1537
- .identity-note,
1538
- .rt-group-empty,
1539
- .plugin-health {
1540
- font-size: 13px;
1541
- }
1542
-
1543
- .rt-group-desc {
1544
- flex: 1 1 100%;
1545
- }
1546
-
1547
- /* 手指命中区:分组操作按钮只有 24px 高 */
1548
- .rt-group-ops .mini-btn,
1549
- .rt-group-head .mini-btn {
1550
- min-width: 40px;
1551
- min-height: 40px;
1552
- padding: 6px 10px;
1553
- }
1554
- }
1555
-
1556
- /* 无 hover 的设备(手机/平板):分组操作不能只在 hover 时才出现 */
1557
- @media (hover: none) {
1558
- .rt-group-ops {
1559
- opacity: 1;
1560
- }
1561
- }
1562
- </style>
1
+ <script setup lang="ts">
2
+ import { message } from 'ant-design-vue'
3
+ import { useUserStore } from '@/app/stores/workshop/user'
4
+ import { useRuntimeConfigStore } from '@/app/stores/runtime-config'
5
+ import { useWorkshopApi, type WorkshopPluginDto } from '@/app/composables/workshop/useWorkshopApi'
6
+ import { apiErrorMessage } from '@/app/utils/api-error'
7
+
8
+ const { t, locale, locales, setLocale } = useI18n()
9
+ const store = useAppStore()
10
+ const site = useSiteConfig()
11
+ const config = useRuntimeConfig().public
12
+ const userStore = useUserStore()
13
+ const api = useWorkshopApi()
14
+
15
+ const activeTab = ref('appearance')
16
+
17
+ /* ================= 运行配置(服务端持久化 + 热重载) ================= */
18
+ const rcStore = useRuntimeConfigStore()
19
+ const draft = ref<Record<string, unknown>>({})
20
+ const dirtyKeys = ref<Set<string>>(new Set())
21
+ const savingRuntime = ref(false)
22
+ const runtimeNotice = ref<{ type: 'success' | 'warning' | 'error', text: string } | null>(null)
23
+
24
+ function syncRuntimeDraft() {
25
+ draft.value = { ...rcStore.effective }
26
+ dirtyKeys.value = new Set()
27
+ }
28
+ // immediate:store 由启动插件在应用初始化时拉取,进入本页时通常已 loaded=true,
29
+ // 非 immediate 的 watch 永不触发 → 草稿恒空、所有值输入框显示为空(表单不可用)。
30
+ watch(() => rcStore.loaded, (v) => {
31
+ if (v) syncRuntimeDraft()
32
+ }, { immediate: true })
33
+ watch(() => rcStore.effective, () => {
34
+ // 外部写入(CLI/其他窗口/文件监听)推来的变化,未编辑时才回填草稿
35
+ if (dirtyKeys.value.size === 0) syncRuntimeDraft()
36
+ }, { deep: true })
37
+
38
+ function markDirty(key: string) {
39
+ dirtyKeys.value.add(key)
40
+ runtimeNotice.value = null
41
+ }
42
+
43
+ async function saveRuntime() {
44
+ if (!dirtyKeys.value.size) return
45
+ savingRuntime.value = true
46
+ runtimeNotice.value = null
47
+ try {
48
+ const patchMap: Record<string, unknown> = {}
49
+ for (const k of dirtyKeys.value) patchMap[k] = draft.value[k]
50
+ const res = await rcStore.patch(patchMap)
51
+ syncRuntimeDraft()
52
+ const restart = res.restartRequired ?? []
53
+ runtimeNotice.value = restart.length
54
+ ? { type: 'warning', text: t('settings.runtime.restartNotice', { keys: restart.join(', ') }) }
55
+ : { type: 'success', text: t('settings.runtime.savedLive') }
56
+ }
57
+ catch (e) {
58
+ const err = e as { response?: { data?: { message?: string } }, message?: string }
59
+ runtimeNotice.value = { type: 'error', text: err?.response?.data?.message || err?.message || String(e) }
60
+ }
61
+ finally {
62
+ savingRuntime.value = false
63
+ }
64
+ }
65
+
66
+ async function resetRuntimeKey(key: string) {
67
+ try {
68
+ await rcStore.patch({ [key]: null })
69
+ // 只收敛被重置的键:整表 sync 会静默丢弃用户其他未保存编辑
70
+ dirtyKeys.value.delete(key)
71
+ draft.value[key] = rcStore.effective[key]
72
+ runtimeNotice.value = { type: 'success', text: t('settings.runtime.keyReset', { key }) }
73
+ }
74
+ catch (e) {
75
+ const err = e as { response?: { data?: { message?: string } }, message?: string }
76
+ runtimeNotice.value = { type: 'error', text: err?.response?.data?.message || err?.message || String(e) }
77
+ }
78
+ }
79
+
80
+ async function resetAllRuntime() {
81
+ try {
82
+ await rcStore.resetAll()
83
+ syncRuntimeDraft()
84
+ runtimeNotice.value = { type: 'success', text: t('settings.runtime.resetAllDone') }
85
+ }
86
+ catch (e) {
87
+ const err = e as { response?: { data?: { message?: string } }, message?: string }
88
+ runtimeNotice.value = { type: 'error', text: err?.response?.data?.message || err?.message || String(e) }
89
+ }
90
+ }
91
+
92
+ /** 内置分组的 i18n 标签(id → i18n key);插件/自建分组走自己的 label */
93
+ const KNOWN_GROUP_LABELS: Record<string, string> = { server: 'settings.runtime.groupServer', app: 'settings.runtime.groupApp', api: 'settings.runtime.groupApi', theme: 'settings.runtime.groupTheme', i18n: 'settings.runtime.groupI18n', security: 'settings.runtime.groupSecurity', daq: 'settings.runtime.groupDaq', memory: 'settings.runtime.groupMemory', omp: 'settings.runtime.groupOmp', harness: 'settings.runtime.groupHarness', dcw: 'settings.runtime.groupDcw', workshop: 'settings.runtime.groupWorkshop', backup: 'settings.runtime.groupBackup', retention: 'settings.runtime.groupRetention', log: 'settings.runtime.groupLog', plugins: 'settings.runtime.groupPlugins' }
94
+
95
+ /**
96
+ * 分组标题解析优先级:
97
+ * 1) 分组自带 labelKey(插件可本地化自己分区的标题)
98
+ * 2) 服务端下发的 label 与 id 不同 —— 管理员改过名 / 插件声明的展示名
99
+ * 3) 内置分组的 i18n 映射(注册表里内置组 label 就是 id,需要前端补语言)
100
+ * 4) 兜底用 id
101
+ */
102
+ function groupLabel(g: { id: string, label?: string, labelKey?: string }): string {
103
+ if (g.labelKey && t(g.labelKey) !== g.labelKey) return t(g.labelKey)
104
+ if (g.label && g.label !== g.id) return g.label
105
+ const known = KNOWN_GROUP_LABELS[g.id]
106
+ if (known) return t(known)
107
+ return g.label || g.id
108
+ }
109
+ function groupSourceBadge(g: { source: string, plugin?: string }): string {
110
+ if (g.source === 'plugin') return g.plugin ? `${t('settings.groups.srcPlugin')}: ${g.plugin}` : t('settings.groups.srcPlugin')
111
+ if (g.source === 'user') return t('settings.groups.srcUser')
112
+ return t('settings.groups.srcBuiltin')
113
+ }
114
+ function sourceClass(s: string): string {
115
+ return s === 'runtime' ? 'src-runtime' : s === 'env' ? 'src-env' : 'src-yaml'
116
+ }
117
+ function itemLabel(item: { label: string, labelKey?: string }): string {
118
+ const k = item.labelKey
119
+ return (k && t(k) !== k) ? t(k) : item.label
120
+ }
121
+
122
+ /* ---------------- 分组折叠 + 分组管理(后端为权威,本地只管展开偏好) ---------------- */
123
+ const GROUP_COLLAPSE_KEY = 'aw.settings.groups.collapsed'
124
+ /** 本地展开/收起偏好:{ [groupId]: collapsed } */
125
+ const groupCollapsed = ref<Record<string, boolean>>({})
126
+ function loadCollapsePref(): void {
127
+ try {
128
+ groupCollapsed.value = JSON.parse(localStorage.getItem(GROUP_COLLAPSE_KEY) ?? '{}') ?? {}
129
+ }
130
+ catch {
131
+ groupCollapsed.value = {}
132
+ }
133
+ }
134
+ function saveCollapsePref(): void {
135
+ try {
136
+ localStorage.setItem(GROUP_COLLAPSE_KEY, JSON.stringify(groupCollapsed.value))
137
+ }
138
+ catch { /* 隐私模式等场景忽略 */ }
139
+ }
140
+ function isCollapsed(g: { id: string, collapsed: boolean }): boolean {
141
+ const local = groupCollapsed.value[g.id]
142
+ return local === undefined ? g.collapsed : local
143
+ }
144
+ function toggleGroup(g: { id: string, collapsed: boolean, collapsible: boolean }): void {
145
+ if (!g.collapsible) return
146
+ groupCollapsed.value = { ...groupCollapsed.value, [g.id]: !isCollapsed(g) }
147
+ saveCollapsePref()
148
+ }
149
+ /** 全部展开/收起(一键;按当前是否已全展开决定方向) */
150
+ function toggleAllGroups(): void {
151
+ const list = rcStore.groups.filter(g => g.collapsible)
152
+ const anyExpanded = list.some(g => !isCollapsed(g))
153
+ const next = { ...groupCollapsed.value }
154
+ for (const g of list) next[g.id] = anyExpanded
155
+ groupCollapsed.value = next
156
+ saveCollapsePref()
157
+ }
158
+ const allExpanded = computed(() => rcStore.groups.filter(g => g.collapsible).some(g => !isCollapsed(g)))
159
+
160
+ /* 分组 CRUD(admin):内联表单 + 行内改名,避免引入全局弹窗依赖 */
161
+ const isAdmin = computed(() => userStore.isAdmin)
162
+ const groupFormOpen = ref(false)
163
+ const groupForm = reactive({ label: '', description: '', collapsed: false })
164
+ const groupBusy = ref('')
165
+ const renamingId = ref('')
166
+ const renameDraft = ref('')
167
+
168
+ async function submitGroupForm(): Promise<void> {
169
+ if (!groupForm.label.trim()) return
170
+ groupBusy.value = 'create'
171
+ try {
172
+ await rcStore.createGroup({ label: groupForm.label.trim(), description: groupForm.description.trim(), collapsed: groupForm.collapsed })
173
+ groupFormOpen.value = false
174
+ groupForm.label = ''
175
+ groupForm.description = ''
176
+ groupForm.collapsed = false
177
+ message.success(t('settings.groups.created'))
178
+ }
179
+ catch (e) {
180
+ message.error(apiErrorMessage(e, t('settings.groups.createFail')))
181
+ }
182
+ finally {
183
+ groupBusy.value = ''
184
+ }
185
+ }
186
+
187
+ function startRename(g: { id: string, label: string }): void {
188
+ renamingId.value = g.id
189
+ renameDraft.value = g.label
190
+ }
191
+ async function commitRename(id: string): Promise<void> {
192
+ const label = renameDraft.value.trim()
193
+ renamingId.value = ''
194
+ if (!label) return
195
+ groupBusy.value = id
196
+ try {
197
+ await rcStore.updateGroup(id, { label })
198
+ message.success(t('settings.groups.renamed'))
199
+ }
200
+ catch (e) {
201
+ message.error(apiErrorMessage(e, t('settings.groups.updateFail')))
202
+ }
203
+ finally {
204
+ groupBusy.value = ''
205
+ }
206
+ }
207
+
208
+ /** 排序:与相邻同源分组交换 order(通过各自的 order 值互换实现稳定重排) */
209
+ async function moveGroup(id: string, dir: -1 | 1): Promise<void> {
210
+ const list = [...rcStore.groups]
211
+ const i = list.findIndex(g => g.id === id)
212
+ const j = i + dir
213
+ if (i < 0 || j < 0 || j >= list.length) return
214
+ const a = list[i]!
215
+ const b = list[j]!
216
+ groupBusy.value = id
217
+ try {
218
+ // 顺序值可能相等(插件组同 order)→ 用「重排后的显式序号」写回,保证结果确定
219
+ const ordered = [...list]
220
+ ordered.splice(i, 1)
221
+ ordered.splice(j, 0, a)
222
+ const patchA = (j + 1) * 10
223
+ const patchB = (i + 1) * 10
224
+ await rcStore.updateGroup(a.id, { order: patchA })
225
+ await rcStore.updateGroup(b.id, { order: patchB })
226
+ void b
227
+ }
228
+ catch (e) {
229
+ message.error(apiErrorMessage(e, t('settings.groups.updateFail')))
230
+ }
231
+ finally {
232
+ groupBusy.value = ''
233
+ }
234
+ }
235
+
236
+ async function setGroupDefaultCollapsed(g: { id: string, collapsed: boolean }): Promise<void> {
237
+ groupBusy.value = g.id
238
+ try {
239
+ await rcStore.updateGroup(g.id, { collapsed: !g.collapsed })
240
+ }
241
+ catch (e) {
242
+ message.error(apiErrorMessage(e, t('settings.groups.updateFail')))
243
+ }
244
+ finally {
245
+ groupBusy.value = ''
246
+ }
247
+ }
248
+
249
+ async function removeGroup(g: { id: string, label: string, fieldCount?: number }): Promise<void> {
250
+ const count = g.fieldCount ?? 0
251
+ if (count > 0) {
252
+ message.warning(t('settings.groups.deleteHasFields', { p0: groupLabel(g), p1: count }))
253
+ return
254
+ }
255
+ groupBusy.value = g.id
256
+ try {
257
+ await rcStore.deleteGroup(g.id)
258
+ message.success(t('settings.groups.deleted'))
259
+ }
260
+ catch (e) {
261
+ message.error(apiErrorMessage(e, t('settings.groups.deleteFail')))
262
+ }
263
+ finally {
264
+ groupBusy.value = ''
265
+ }
266
+ }
267
+ /* ============================================================= */
268
+
269
+ /* ================= 插件管理(清单/启停/健康检测) ================= */
270
+ interface PluginHealth {
271
+ ok: boolean
272
+ reason?: string
273
+ }
274
+
275
+ const plugins = ref<WorkshopPluginDto[]>([])
276
+ const pluginsLoading = ref(false)
277
+ const pluginsLoaded = ref(false)
278
+ const togglingPlugins = ref<Set<string>>(new Set())
279
+ const healthMap = ref<Record<string, PluginHealth | 'checking'>>({})
280
+
281
+ const hasHealthRoute = (p: WorkshopPluginDto): boolean =>
282
+ (p.routes ?? []).some(r => String(r.path || '').endsWith('/health'))
283
+
284
+ async function loadPlugins() {
285
+ pluginsLoading.value = true
286
+ try {
287
+ const res = await api.listPlugins()
288
+ // 顶层 plugins key(非信封);兼容 {code,data} 信封
289
+ const list = res?.plugins ?? (res as { data?: { plugins?: WorkshopPluginDto[] } })?.data?.plugins ?? []
290
+ plugins.value = Array.isArray(list) ? list : []
291
+ pluginsLoaded.value = true
292
+ }
293
+ catch (e) {
294
+ message.error(apiErrorMessage(e, t('plugins.loadFail')))
295
+ }
296
+ finally {
297
+ pluginsLoading.value = false
298
+ }
299
+ }
300
+
301
+ async function togglePlugin(p: WorkshopPluginDto, next: boolean) {
302
+ if (togglingPlugins.value.has(p.name)) return
303
+ togglingPlugins.value.add(p.name)
304
+ try {
305
+ if (next) await api.enablePlugin(p.name)
306
+ else await api.disablePlugin(p.name)
307
+ p.enabled = next
308
+ message.success(next ? t('plugins.enabled') : t('plugins.disabled'))
309
+ }
310
+ catch (e) {
311
+ // 普通用户 403 → 需要管理员权限
312
+ message.error(apiErrorMessage(e, t('plugins.adminRequired')))
313
+ }
314
+ finally {
315
+ togglingPlugins.value.delete(p.name)
316
+ }
317
+ }
318
+
319
+ async function checkPluginHealth(p: WorkshopPluginDto) {
320
+ if (!hasHealthRoute(p)) return
321
+ healthMap.value = { ...healthMap.value, [p.name]: 'checking' }
322
+ try {
323
+ const res = await api.pluginHealth(p.name)
324
+ healthMap.value = { ...healthMap.value, [p.name]: judgePluginHealth(p.name, res) }
325
+ }
326
+ catch (e) {
327
+ healthMap.value = { ...healthMap.value, [p.name]: { ok: false, reason: apiErrorMessage(e, t('plugins.loadFail')) } }
328
+ }
329
+ }
330
+
331
+ /** 健康判定:rag-bridge 看 backend.ok && web.ok;diag-bridge 看 remote.status==='ok';未知形状回退无报错即视为正常 */
332
+ function judgePluginHealth(name: string, res: Record<string, unknown>): PluginHealth {
333
+ if (name === 'rag-bridge') {
334
+ const backend = res.backend as { ok?: boolean, error?: string, status?: string } | undefined
335
+ const web = res.web as { ok?: boolean, error?: string, status?: string } | undefined
336
+ const ok = Boolean(backend?.ok) && Boolean(web?.ok)
337
+ const reason = !backend?.ok
338
+ ? (backend?.error || backend?.status || 'backend')
339
+ : (web?.error || web?.status || 'web')
340
+ return ok ? { ok: true } : { ok: false, reason }
341
+ }
342
+ if (name === 'diag-bridge') {
343
+ const remote = res.remote as { status?: string } | undefined
344
+ const ok = remote?.status === 'ok'
345
+ return ok ? { ok: true } : { ok: false, reason: remote?.status || 'remote' }
346
+ }
347
+ return { ok: true }
348
+ }
349
+
350
+ /** 模板辅助:检测中 / 检测结果(避免模板内窄化) */
351
+ function isCheckingHealth(name: string): boolean {
352
+ return healthMap.value[name] === 'checking'
353
+ }
354
+ function healthOf(name: string): PluginHealth | null {
355
+ const h = healthMap.value[name]
356
+ return (h && h !== 'checking') ? h : null
357
+ }
358
+ function isToggling(name: string): boolean {
359
+ return togglingPlugins.value.has(name)
360
+ }
361
+
362
+ // 进入运行配置 Tab 时懒加载插件清单(端点需登录)
363
+ watch(activeTab, (v) => {
364
+ if (v === 'runtime' && !pluginsLoaded.value && !pluginsLoading.value && userStore.isLoggedIn) void loadPlugins()
365
+ }, { immediate: true })
366
+
367
+ // 折叠偏好只在浏览器侧读写(localStorage);SSR 首帧用服务端默认折叠态,水合后无缝接管
368
+ onMounted(loadCollapsePref)
369
+ /* ============================================================= */
370
+
371
+ /** 强调色预设(控制室低饱和族;默认 = 品牌绿,跟随 config.yml) */
372
+ const defaultAccent = String(config.primaryColor)
373
+ const colorPresets = [
374
+ { value: defaultAccent, label: t('settings.accentInk') },
375
+ { value: '#c9963f', label: t('settings.accentStone') },
376
+ { value: '#44615a', label: t('settings.accentMoss') },
377
+ { value: '#3f6094', label: t('settings.accentSlate') },
378
+ { value: '#6b5aa0', label: t('settings.accentPlum') },
379
+ { value: '#9c5744', label: t('settings.accentClay') },
380
+ ]
381
+
382
+ const currentAccent = computed(() => store.accent ?? defaultAccent)
383
+
384
+ function pickAccent(c: string) {
385
+ store.setAccent(c === defaultAccent ? null : c)
386
+ message.success(t('settings.accentApplied'))
387
+ }
388
+
389
+ /** 恢复默认(品牌绿,跟随 config.yml) */
390
+ function resetAccent() {
391
+ store.setAccent(null)
392
+ message.success(t('settings.accentReset'))
393
+ }
394
+
395
+ const localeOptions = computed(() =>
396
+ (locales.value as Array<{ code: string, name: string }>).map(l => ({
397
+ label: l.name,
398
+ value: l.code,
399
+ })),
400
+ )
401
+
402
+ const switchLocale = (value: unknown) => {
403
+ if (value != null) {
404
+ // 持久化 + 强刷:与 AppHeader 同策略(setup 期词条需重载整体切换)
405
+ // cookie 参与 SSR 首帧(locale-cookie.global.ts);localStorage 保留为插件回退
406
+ useCookie('aw.locale', { maxAge: 60 * 60 * 24 * 365 }).value = String(value)
407
+ localStorage.setItem('aw.locale', String(value))
408
+ setLocale(String(value) as 'zh-CN' | 'en')
409
+ window.location.reload()
410
+ }
411
+ }
412
+
413
+ /** 系统态概览(只读,同源 config.yml) */
414
+ const systemRows = computed(() => [
415
+ { label: t('home.fields.name'), value: site.name },
416
+ { label: t('home.fields.version'), value: `v${site.version}` },
417
+ { label: t('home.fields.mode'), value: site.mode },
418
+ { label: t('home.fields.apiBase'), value: `${site.apiBase} (${config.apiTimeout}ms)` },
419
+ { label: t('home.fields.primary'), value: defaultAccent },
420
+ { label: 'i18n', value: locale.value },
421
+ ])
422
+
423
+ const tabs = computed(() => [
424
+ { key: 'appearance', icon: 'i-tabler-palette', label: t('settings.theme') },
425
+ { key: 'runtime', icon: 'i-tabler-settings', label: t('settings.runtimeTab') },
426
+ { key: 'system', icon: 'i-tabler-server-2', label: t('settings.systemTab') },
427
+ ])
428
+ </script>
429
+
430
+ <template>
431
+ <div class="settings-page">
432
+ <div class="aw-page-head">
433
+ <div>
434
+ <p class="aw-kicker">
435
+ {{ t('menu.system') }} / settings
436
+ </p>
437
+ <h1>{{ t('settings.title') }}</h1>
438
+ </div>
439
+ <span class="aw-stamp">v{{ site.version }}</span>
440
+ </div>
441
+
442
+ <a-card
443
+ :bordered="false"
444
+ class="settings-card"
445
+ >
446
+ <div class="settings-layout">
447
+ <!-- 左侧 Tab 导航 -->
448
+ <div class="settings-nav">
449
+ <button
450
+ v-for="tab in tabs"
451
+ :key="tab.key"
452
+ class="nav-item"
453
+ :class="{ active: activeTab === tab.key }"
454
+ @click="activeTab = tab.key"
455
+ >
456
+ <span :class="tab.icon" />
457
+ <span>{{ tab.label }}</span>
458
+ </button>
459
+ </div>
460
+
461
+ <!-- 右侧内容区 -->
462
+ <div class="settings-body">
463
+ <!-- 外观:主题色 / 深浅 / 语言(全部实时生效并持久化) -->
464
+ <div v-show="activeTab === 'appearance'">
465
+ <h3 class="section-title">
466
+ {{ t('settings.theme') }}
467
+ </h3>
468
+ <p class="section-desc">
469
+ {{ t('settings.appearanceDesc') }}
470
+ </p>
471
+
472
+ <div class="set-row">
473
+ <div class="set-text">
474
+ <div class="set-title">
475
+ {{ t('settings.primaryColor') }}
476
+ </div>
477
+ <div class="set-sub">
478
+ {{ t('settings.accentDesc') }}
479
+ </div>
480
+ </div>
481
+ </div>
482
+ <div class="color-swatches">
483
+ <button
484
+ v-for="c in colorPresets"
485
+ :key="c.value"
486
+ class="swatch"
487
+ :class="{ on: currentAccent.toLowerCase() === c.value.toLowerCase() }"
488
+ :style="{ '--sw': c.value }"
489
+ :title="c.label"
490
+ @click="pickAccent(c.value)"
491
+ >
492
+ <span class="swatch-dot" />
493
+ <span class="swatch-label">{{ c.label }}</span>
494
+ <span
495
+ v-if="currentAccent.toLowerCase() === c.value.toLowerCase()"
496
+ class="i-tabler-check swatch-check"
497
+ />
498
+ </button>
499
+ </div>
500
+ <button
501
+ class="aw-pill outline reset-btn"
502
+ @click="resetAccent"
503
+ >
504
+ <span class="i-tabler-rotate" />
505
+ {{ t('settings.accentResetAction') }}
506
+ </button>
507
+
508
+ <div class="toggle-row">
509
+ <div class="set-text">
510
+ <div class="set-title">
511
+ {{ store.isDark ? t('common.dark') : t('common.light') }}
512
+ </div>
513
+ <div class="set-sub">
514
+ {{ t('settings.darkDesc') }}
515
+ </div>
516
+ </div>
517
+ <button
518
+ class="switch"
519
+ :class="{ on: store.isDark }"
520
+ role="switch"
521
+ :aria-checked="store.isDark"
522
+ @click="store.toggleDark()"
523
+ >
524
+ <span class="knob" />
525
+ </button>
526
+ </div>
527
+
528
+ <div class="toggle-row">
529
+ <div class="set-text">
530
+ <div class="set-title">
531
+ {{ t('header.language') }}
532
+ </div>
533
+ <div class="set-sub">
534
+ {{ t('settings.languageDesc') }}
535
+ </div>
536
+ </div>
537
+ <a-select
538
+ :value="locale"
539
+ :options="localeOptions"
540
+ style="width: 140px"
541
+ @change="switchLocale"
542
+ />
543
+ </div>
544
+ </div>
545
+
546
+ <!-- 运行配置:服务端持久化 + live 热重载/restart 重启生效 -->
547
+ <div v-show="activeTab === 'runtime'">
548
+ <h3 class="section-title">
549
+ {{ t('settings.runtimeTab') }}
550
+ </h3>
551
+ <p class="section-desc">
552
+ {{ t('settings.runtime.desc') }}
553
+ </p>
554
+
555
+ <a-alert
556
+ v-if="runtimeNotice"
557
+ :type="runtimeNotice.type"
558
+ show-icon
559
+ class="rt-notice"
560
+ :message="runtimeNotice.text"
561
+ />
562
+
563
+ <!-- 分组工具条:一键展开/收起 + (admin) 新建分组 -->
564
+ <div class="rt-groups-bar">
565
+ <button
566
+ class="aw-pill outline rt-groups-toggle"
567
+ :title="t('settings.groups.toggleAll')"
568
+ @click="toggleAllGroups"
569
+ >
570
+ <span :class="allExpanded ? 'i-tabler-chevrons-up' : 'i-tabler-chevrons-down'" />
571
+ {{ allExpanded ? t('settings.groups.collapseAll') : t('settings.groups.expandAll') }}
572
+ </button>
573
+ <span class="rt-groups-count">{{ t('settings.groups.count', { p0: rcStore.groups.length }) }}</span>
574
+ <button
575
+ v-if="isAdmin"
576
+ class="aw-pill outline rt-groups-add"
577
+ @click="groupFormOpen = !groupFormOpen"
578
+ >
579
+ <span class="i-tabler-plus" />
580
+ {{ t('settings.groups.newGroup') }}
581
+ </button>
582
+ </div>
583
+
584
+ <!-- 新建分组(内联表单;id 由服务端按 label 派生,创建后即可用 API 归属字段) -->
585
+ <div
586
+ v-if="isAdmin && groupFormOpen"
587
+ class="rt-group-form"
588
+ >
589
+ <a-input
590
+ v-model:value="groupForm.label"
591
+ :placeholder="t('settings.groups.namePlaceholder')"
592
+ style="width: 220px"
593
+ @press-enter="submitGroupForm"
594
+ />
595
+ <a-input
596
+ v-model:value="groupForm.description"
597
+ :placeholder="t('settings.groups.descPlaceholder')"
598
+ style="width: 320px"
599
+ @press-enter="submitGroupForm"
600
+ />
601
+ <a-checkbox v-model:checked="groupForm.collapsed">
602
+ {{ t('settings.groups.defaultCollapsed') }}
603
+ </a-checkbox>
604
+ <button
605
+ class="aw-pill primary"
606
+ :disabled="!groupForm.label.trim() || groupBusy === 'create'"
607
+ @click="submitGroupForm"
608
+ >
609
+ {{ groupBusy === 'create' ? '…' : t('settings.groups.create') }}
610
+ </button>
611
+ <button
612
+ class="aw-pill outline"
613
+ @click="groupFormOpen = false"
614
+ >
615
+ {{ t('settings.groups.cancel') }}
616
+ </button>
617
+ </div>
618
+
619
+ <!-- 分组分区:顺序/标题/折叠态全部来自后端 groups 接口 -->
620
+ <section
621
+ v-for="g in rcStore.groups"
622
+ :key="g.id"
623
+ class="rt-group"
624
+ :class="{ 'collapsed': isCollapsed(g), 'grp-plugin': g.source === 'plugin' }"
625
+ >
626
+ <header
627
+ class="rt-group-head"
628
+ :role="g.collapsible ? 'button' : undefined"
629
+ :tabindex="g.collapsible ? 0 : undefined"
630
+ @click="toggleGroup(g)"
631
+ @keydown.enter.prevent="toggleGroup(g)"
632
+ @keydown.space.prevent="toggleGroup(g)"
633
+ >
634
+ <span
635
+ v-if="g.collapsible"
636
+ class="rt-caret"
637
+ :class="isCollapsed(g) ? 'i-tabler-chevron-right' : 'i-tabler-chevron-down'"
638
+ />
639
+ <span
640
+ v-if="g.icon"
641
+ class="rt-group-icon"
642
+ :class="g.icon"
643
+ />
644
+ <template v-if="renamingId === g.id">
645
+ <input
646
+ v-model="renameDraft"
647
+ class="rt-rename aw-mono"
648
+ @click.stop
649
+ @keydown.enter.prevent="commitRename(g.id)"
650
+ @keydown.esc.prevent="renamingId = ''"
651
+ >
652
+ <button
653
+ class="mini-btn"
654
+ @click.stop="commitRename(g.id)"
655
+ >
656
+ {{ t('settings.groups.ok') }}
657
+ </button>
658
+ </template>
659
+ <h4
660
+ v-else
661
+ class="rt-group-title"
662
+ >
663
+ {{ groupLabel(g) }}
664
+ </h4>
665
+ <span class="rt-group-badge">{{ groupSourceBadge(g) }}</span>
666
+ <span class="rt-group-fields">{{ t('settings.groups.fields', { p0: g.fieldCount ?? 0 }) }}</span>
667
+ <span
668
+ v-if="g.description"
669
+ class="rt-group-desc"
670
+ >{{ g.description }}</span>
671
+ <!-- 分组操作(admin):插件声明的分组由插件权威,不提供改名/删除 -->
672
+ <span
673
+ v-if="isAdmin && g.source !== 'plugin'"
674
+ class="rt-group-ops"
675
+ @click.stop
676
+ >
677
+ <button
678
+ class="mini-btn"
679
+ :disabled="groupBusy === g.id"
680
+ :title="t('settings.groups.moveUp')"
681
+ @click="moveGroup(g.id, -1)"
682
+ >
683
+ <span class="i-tabler-arrow-up" />
684
+ </button>
685
+ <button
686
+ class="mini-btn"
687
+ :disabled="groupBusy === g.id"
688
+ :title="t('settings.groups.moveDown')"
689
+ @click="moveGroup(g.id, 1)"
690
+ >
691
+ <span class="i-tabler-arrow-down" />
692
+ </button>
693
+ <button
694
+ class="mini-btn"
695
+ :disabled="groupBusy === g.id"
696
+ :title="t('settings.groups.rename')"
697
+ @click="startRename(g)"
698
+ >
699
+ <span class="i-tabler-pencil" />
700
+ </button>
701
+ <button
702
+ class="mini-btn"
703
+ :disabled="groupBusy === g.id"
704
+ :title="t('settings.groups.defaultCollapsed')"
705
+ @click="setGroupDefaultCollapsed(g)"
706
+ >
707
+ <span :class="g.collapsed ? 'i-tabler-chevrons-down' : 'i-tabler-chevrons-up'" />
708
+ </button>
709
+ <button
710
+ v-if="g.source === 'user'"
711
+ class="mini-btn"
712
+ :disabled="groupBusy === g.id"
713
+ :title="t('settings.groups.delete')"
714
+ @click="removeGroup(g)"
715
+ >
716
+ <span class="i-tabler-trash" />
717
+ </button>
718
+ </span>
719
+ </header>
720
+
721
+ <div
722
+ v-show="!isCollapsed(g)"
723
+ class="rt-group-body"
724
+ >
725
+ <p
726
+ v-if="!(g.fieldCount ?? 0)"
727
+ class="rt-group-empty"
728
+ >
729
+ {{ t('settings.groups.emptyHint') }}
730
+ </p>
731
+ <div
732
+ v-for="item in rcStore.fieldsOf(g.id)"
733
+ :key="item.key"
734
+ class="rt-row"
735
+ >
736
+ <div class="rt-main">
737
+ <div class="rt-title">
738
+ {{ itemLabel(item) }}
739
+ <span
740
+ class="rt-tag"
741
+ :class="sourceClass(rcStore.sourceOf(item.key))"
742
+ >{{ rcStore.sourceOf(item.key) }}</span>
743
+ <span
744
+ class="rt-tag"
745
+ :class="item.applies"
746
+ >{{ item.applies === 'live' ? t('settings.runtime.live') : t('settings.runtime.restart') }}</span>
747
+ </div>
748
+ <div class="rt-sub">
749
+ {{ item.description }}
750
+ </div>
751
+ </div>
752
+ <div class="rt-ctrl">
753
+ <a-input-number
754
+ v-if="item.type === 'number'"
755
+ v-model:value="(draft[item.key] as string | number | undefined)"
756
+ :min="item.min"
757
+ :max="item.max"
758
+ @change="markDirty(item.key)"
759
+ />
760
+ <a-switch
761
+ v-else-if="item.type === 'boolean'"
762
+ v-model:checked="(draft[item.key] as string | number | boolean | undefined)"
763
+ @change="markDirty(item.key)"
764
+ />
765
+ <a-select
766
+ v-else-if="item.type === 'select'"
767
+ v-model:value="(draft[item.key] as string | number | undefined)"
768
+ style="width: 160px"
769
+ :options="(item.options ?? []).map(o => ({ label: o, value: o }))"
770
+ @change="markDirty(item.key)"
771
+ />
772
+ <span
773
+ v-else-if="item.type === 'color'"
774
+ class="rt-color"
775
+ >
776
+ <input
777
+ type="color"
778
+ :value="String(draft[item.key] ?? '#35e0a0')"
779
+ @input="draft[item.key] = ($event.target as HTMLInputElement).value; markDirty(item.key)"
780
+ >
781
+ <a-input
782
+ :value="String(draft[item.key] ?? '')"
783
+ style="width: 110px"
784
+ class="aw-mono"
785
+ @change="draft[item.key] = ($event.target as HTMLInputElement).value; markDirty(item.key)"
786
+ />
787
+ </span>
788
+ <a-input
789
+ v-else
790
+ v-model:value="(draft[item.key] as string | number | undefined)"
791
+ style="width: 220px"
792
+ @change="markDirty(item.key)"
793
+ />
794
+ <button
795
+ class="aw-pill outline rt-reset"
796
+ :title="t('settings.runtime.resetKey')"
797
+ @click="resetRuntimeKey(item.key)"
798
+ >
799
+ <span class="i-tabler-rotate" />
800
+ </button>
801
+ </div>
802
+ </div>
803
+ </div>
804
+ </section>
805
+
806
+ <div class="rt-actions">
807
+ <button
808
+ class="aw-pill primary"
809
+ :disabled="!dirtyKeys.size || savingRuntime"
810
+ @click="saveRuntime"
811
+ >
812
+ <span class="i-tabler-device-floppy" />
813
+ {{ savingRuntime ? '…' : t('settings.runtime.save') }}
814
+ </button>
815
+ <button
816
+ class="aw-pill outline"
817
+ @click="resetAllRuntime"
818
+ >
819
+ {{ t('settings.runtime.resetAll') }}
820
+ </button>
821
+ <span class="rt-path aw-mono">{{ rcStore.settingsPath }}</span>
822
+ </div>
823
+
824
+ <!-- 插件管理:清单 / 启停(admin) / 健康检测 -->
825
+ <h4 class="rt-group-title plugin-group-title">
826
+ {{ t('plugins.title') }}
827
+ </h4>
828
+ <p class="section-desc">
829
+ {{ t('plugins.desc') }}
830
+ </p>
831
+
832
+ <div
833
+ v-if="!userStore.isLoggedIn"
834
+ class="identity-note"
835
+ >
836
+ {{ t('plugins.needLogin') }}
837
+ </div>
838
+ <a-spin
839
+ v-else
840
+ :spinning="pluginsLoading"
841
+ >
842
+ <div
843
+ v-for="p in plugins"
844
+ :key="p.name"
845
+ class="plugin-row"
846
+ :class="{ off: p.enabled === false }"
847
+ >
848
+ <div class="plugin-main">
849
+ <div class="rt-title">
850
+ <span class="aw-mono">{{ p.name }}</span>
851
+ <span
852
+ v-if="p.version"
853
+ class="rt-tag"
854
+ >v{{ p.version }}</span>
855
+ <span
856
+ v-if="p.builtin"
857
+ class="rt-tag src-runtime"
858
+ >{{ t('plugins.builtin') }}</span>
859
+ <span
860
+ v-if="p.error"
861
+ class="rt-tag restart"
862
+ >{{ p.error }}</span>
863
+ </div>
864
+ <div class="rt-sub">
865
+ {{ p.description || '—' }}
866
+ </div>
867
+ </div>
868
+ <div class="rt-ctrl">
869
+ <span
870
+ v-if="isCheckingHealth(p.name)"
871
+ class="plugin-health faint"
872
+ >
873
+ <span class="i-tabler-loader-2 spin" />
874
+ {{ t('plugins.healthChecking') }}
875
+ </span>
876
+ <span
877
+ v-else-if="healthOf(p.name)"
878
+ class="plugin-health"
879
+ :class="healthOf(p.name)!.ok ? 'ok' : 'bad'"
880
+ >
881
+ <span :class="healthOf(p.name)!.ok ? 'i-tabler-circle-check' : 'i-tabler-alert-circle'" />
882
+ {{ healthOf(p.name)!.ok ? t('plugins.healthOk') : `${t('plugins.healthBad')}: ${healthOf(p.name)!.reason ?? ''}` }}
883
+ </span>
884
+ <button
885
+ v-if="hasHealthRoute(p)"
886
+ class="aw-pill outline rt-reset"
887
+ @click="checkPluginHealth(p)"
888
+ >
889
+ <span class="i-tabler-heartbeat" />
890
+ {{ t('plugins.healthCheck') }}
891
+ </button>
892
+ <button
893
+ class="switch"
894
+ :class="{ on: p.enabled !== false }"
895
+ role="switch"
896
+ :aria-checked="p.enabled !== false"
897
+ :disabled="isToggling(p.name)"
898
+ @click="togglePlugin(p, p.enabled === false)"
899
+ >
900
+ <span class="knob" />
901
+ </button>
902
+ </div>
903
+ </div>
904
+ <div
905
+ v-if="plugins.length === 0 && !pluginsLoading"
906
+ class="identity-note"
907
+ >
908
+ {{ t('plugins.empty') }}
909
+ </div>
910
+ </a-spin>
911
+
912
+ <!-- 插件 UI 注入区:client 面板经 ctx.ui.registerPanel({slot:'settings.plugins'}) 注入 -->
913
+ <workshop-plugin-slot
914
+ v-if="plugins.length"
915
+ slot-name="settings.plugins"
916
+ />
917
+ </div>
918
+
919
+ <!-- 系统:只读运行参数 -->
920
+ <div v-show="activeTab === 'system'">
921
+ <h3 class="section-title">
922
+ {{ t('settings.systemTab') }}
923
+ </h3>
924
+ <p class="section-desc">
925
+ {{ t('settings.systemDesc') }}
926
+ </p>
927
+ <a-descriptions
928
+ bordered
929
+ :column="1"
930
+ size="small"
931
+ >
932
+ <a-descriptions-item
933
+ v-for="row in systemRows"
934
+ :key="row.label"
935
+ :label="row.label"
936
+ >
937
+ <span class="aw-mono">{{ row.value }}</span>
938
+ </a-descriptions-item>
939
+ </a-descriptions>
940
+ <p class="identity-note">
941
+ {{ userStore.isLoggedIn
942
+ ? `${t('settings.identity')}: ${userStore.user?.name} (${userStore.user?.role})`
943
+ : t('settings.identityGuest') }}
944
+ </p>
945
+ </div>
946
+ </div>
947
+ </div>
948
+ </a-card>
949
+ </div>
950
+ </template>
951
+
952
+ <style scoped>
953
+ .settings-page {
954
+ padding: 4px;
955
+ }
956
+
957
+ .settings-card {
958
+ overflow: hidden;
959
+ }
960
+
961
+ .settings-layout {
962
+ display: flex;
963
+ min-height: 420px;
964
+ }
965
+
966
+ /* 左侧导航:8px 圆角条目(active = surface-strong 墨字) */
967
+ .settings-nav {
968
+ display: flex;
969
+ flex: 0 0 180px;
970
+ flex-direction: column;
971
+ gap: 2px;
972
+ padding: 14px;
973
+ border-right: 1px solid var(--line);
974
+ }
975
+
976
+ .nav-item {
977
+ display: flex;
978
+ gap: 9px;
979
+ align-items: center;
980
+ width: 100%;
981
+ padding: 8px 10px;
982
+ font-family: var(--font-body);
983
+ font-size: 13px;
984
+ font-weight: 500;
985
+ color: var(--ink-faint);
986
+ text-align: left;
987
+ cursor: pointer;
988
+ background: transparent;
989
+ border: 0;
990
+ border-radius: var(--radius-panel-sm);
991
+ transition: background var(--transition-fast), color var(--transition-fast);
992
+ }
993
+
994
+ .nav-item:hover {
995
+ color: var(--ink);
996
+ background: var(--paper-deep);
997
+ }
998
+
999
+ .nav-item.active {
1000
+ font-weight: 600;
1001
+ color: var(--ink);
1002
+ background: var(--paper-deep);
1003
+ }
1004
+
1005
+ .settings-body {
1006
+ flex: 1;
1007
+ min-width: 0;
1008
+ padding: 22px 26px;
1009
+ }
1010
+
1011
+ .section-title {
1012
+ margin: 0 0 4px;
1013
+ font-family: var(--font-display);
1014
+ font-size: 22px;
1015
+ font-weight: 400;
1016
+ letter-spacing: -0.01em;
1017
+ color: var(--ink);
1018
+ }
1019
+
1020
+ .section-desc {
1021
+ margin: 0 0 18px;
1022
+ font-size: 13px;
1023
+ line-height: 1.6;
1024
+ color: var(--ink-faint);
1025
+ }
1026
+
1027
+ /* 色板:粉彩圆点 + 名称;选中 = 墨描边 */
1028
+ .color-swatches {
1029
+ display: flex;
1030
+ flex-wrap: wrap;
1031
+ gap: 10px;
1032
+ margin: 12px 0 4px;
1033
+ }
1034
+
1035
+ .swatch {
1036
+ position: relative;
1037
+ display: inline-flex;
1038
+ gap: 8px;
1039
+ align-items: center;
1040
+ padding: 8px 14px;
1041
+ font-family: var(--font-body);
1042
+ font-size: 13px;
1043
+ font-weight: 500;
1044
+ color: var(--ink-soft);
1045
+ cursor: pointer;
1046
+ background: var(--paper-raised);
1047
+ border: 1px solid var(--line-strong);
1048
+ border-radius: var(--radius-pill);
1049
+ transition: border-color var(--transition-fast), background var(--transition-fast);
1050
+ }
1051
+
1052
+ .swatch:hover {
1053
+ background: var(--paper-deep);
1054
+ }
1055
+
1056
+ .swatch.on {
1057
+ color: var(--ink);
1058
+ border-color: var(--ink);
1059
+ }
1060
+
1061
+ .swatch-dot {
1062
+ width: 14px;
1063
+ height: 14px;
1064
+ background: var(--sw);
1065
+ border: 1px solid rgb(0 0 0 / 8%);
1066
+ border-radius: 50%;
1067
+ }
1068
+
1069
+ .swatch-check {
1070
+ font-size: 13px;
1071
+ color: var(--ink);
1072
+ }
1073
+
1074
+ .reset-btn {
1075
+ margin: 10px 0 4px;
1076
+ }
1077
+
1078
+ /* 开关行(设置页 toggle row) */
1079
+ .toggle-row,
1080
+ .set-row {
1081
+ display: flex;
1082
+ align-items: center;
1083
+ justify-content: space-between;
1084
+ gap: 20px;
1085
+ padding: 18px 0;
1086
+ border-bottom: 1px solid var(--line);
1087
+ }
1088
+
1089
+ .set-text {
1090
+ min-width: 0;
1091
+ }
1092
+
1093
+ .set-title {
1094
+ font-size: 14px;
1095
+ font-weight: 500;
1096
+ color: var(--ink-soft);
1097
+ }
1098
+
1099
+ .set-sub {
1100
+ max-width: 52ch;
1101
+ margin-top: 3px;
1102
+ font-size: 13px;
1103
+ line-height: 1.5;
1104
+ color: var(--ink-faint);
1105
+ }
1106
+
1107
+ /* 药丸开关(ink on 态) */
1108
+ .switch {
1109
+ position: relative;
1110
+ flex: none;
1111
+ width: 42px;
1112
+ height: 25px;
1113
+ padding: 0;
1114
+ cursor: pointer;
1115
+ background: var(--line-strong);
1116
+ border: 0;
1117
+ border-radius: var(--radius-pill);
1118
+ transition: background 0.18s cubic-bezier(0.22, 1, 0.36, 1);
1119
+ }
1120
+
1121
+ .switch .knob {
1122
+ position: absolute;
1123
+ top: 3px;
1124
+ left: 3px;
1125
+ width: 19px;
1126
+ height: 19px;
1127
+ background: var(--paper-raised);
1128
+ border-radius: 50%;
1129
+ box-shadow: 0 1px 2px rgb(12 10 9 / 25%);
1130
+ transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
1131
+ }
1132
+
1133
+ .switch.on {
1134
+ background: var(--accent);
1135
+ }
1136
+
1137
+ .switch.on .knob {
1138
+ transform: translateX(17px);
1139
+ }
1140
+
1141
+ @media (prefers-reduced-motion: reduce) {
1142
+ .switch,
1143
+ .switch .knob {
1144
+ transition: none;
1145
+ }
1146
+ }
1147
+
1148
+ .identity-note {
1149
+ margin: 14px 0 0;
1150
+ font-size: 12.5px;
1151
+ color: var(--ink-faint);
1152
+ }
1153
+
1154
+ /* ============ 运行配置标签 ============ */
1155
+ .rt-notice {
1156
+ margin: 0 0 14px;
1157
+ }
1158
+
1159
+ /* ── 分组分区(后端 groups 接口驱动:顺序/标题/折叠态均来自服务端) ── */
1160
+ .rt-groups-bar {
1161
+ display: flex;
1162
+ gap: 8px;
1163
+ align-items: center;
1164
+ margin: 12px 0 10px;
1165
+ }
1166
+
1167
+ .rt-groups-count {
1168
+ font-size: 11.5px;
1169
+ color: var(--ink-faint);
1170
+ }
1171
+
1172
+ .rt-groups-add {
1173
+ margin-left: auto;
1174
+ }
1175
+
1176
+ .rt-group-form {
1177
+ display: flex;
1178
+ flex-wrap: wrap;
1179
+ gap: 8px;
1180
+ align-items: center;
1181
+ padding: 10px 12px;
1182
+ margin-bottom: 12px;
1183
+ background: var(--frost-bg);
1184
+ border: 1px solid var(--glass-line);
1185
+ border-radius: 10px;
1186
+ }
1187
+
1188
+ .rt-group {
1189
+ margin: 0 0 10px;
1190
+ background: var(--surface-glass);
1191
+ border: 1px solid var(--glass-line);
1192
+ border-radius: 10px;
1193
+ }
1194
+
1195
+ /* 插件声明的分区:左侧一道强调色,与平台内置分区一眼可分 */
1196
+ .rt-group.grp-plugin {
1197
+ border-left: 3px solid color-mix(in srgb, var(--tone-info-dot) 55%, transparent);
1198
+ }
1199
+
1200
+ .rt-group-head {
1201
+ display: flex;
1202
+ flex-wrap: wrap;
1203
+ gap: 8px;
1204
+ align-items: center;
1205
+ padding: 10px 14px;
1206
+ border-radius: 10px;
1207
+ }
1208
+
1209
+ .rt-group.collapsed .rt-group-head {
1210
+ border-radius: 10px;
1211
+ }
1212
+
1213
+ .rt-group-head[role='button'] {
1214
+ cursor: pointer;
1215
+ }
1216
+
1217
+ .rt-group-head[role='button']:hover {
1218
+ background: var(--hover-tint);
1219
+ }
1220
+
1221
+ .rt-caret {
1222
+ flex: none;
1223
+ font-size: 15px;
1224
+ color: var(--ink-faint);
1225
+ }
1226
+
1227
+ .rt-group-icon {
1228
+ flex: none;
1229
+ font-size: 14px;
1230
+ color: var(--accent);
1231
+ }
1232
+
1233
+ .rt-rename {
1234
+ width: 200px;
1235
+ padding: 2px 8px;
1236
+ font-size: 14px;
1237
+ color: var(--ink);
1238
+ background: var(--paper-deep);
1239
+ border: 1px solid var(--accent);
1240
+ border-radius: var(--radius-chip);
1241
+ }
1242
+
1243
+ .rt-group-badge {
1244
+ flex: none;
1245
+ padding: 1px 7px;
1246
+ font-size: 11.5px;
1247
+ color: var(--ink-faint);
1248
+ border: 1px solid var(--glass-line);
1249
+ border-radius: 99px;
1250
+ }
1251
+
1252
+ .rt-group.grp-plugin .rt-group-badge {
1253
+ color: var(--tone-info-dot);
1254
+ border-color: color-mix(in srgb, var(--tone-info-dot) 40%, transparent);
1255
+ }
1256
+
1257
+ .rt-group-fields {
1258
+ flex: none;
1259
+ font-size: 11.5px;
1260
+ color: var(--ink-faint);
1261
+ }
1262
+
1263
+ .rt-group-desc {
1264
+ min-width: 0;
1265
+ font-size: 11.5px;
1266
+ color: var(--ink-faint);
1267
+ overflow: hidden;
1268
+ text-overflow: ellipsis;
1269
+ white-space: nowrap;
1270
+ }
1271
+
1272
+ .rt-group-ops {
1273
+ display: flex;
1274
+ gap: 2px;
1275
+ align-items: center;
1276
+ margin-left: auto;
1277
+ opacity: 0;
1278
+ transition: opacity 0.15s ease;
1279
+ }
1280
+
1281
+ .rt-group-head:hover .rt-group-ops,
1282
+ .rt-group-ops:focus-within {
1283
+ opacity: 1;
1284
+ }
1285
+
1286
+ .rt-group-body {
1287
+ padding: 0 14px 6px;
1288
+ border-top: 1px solid var(--line);
1289
+ }
1290
+
1291
+ .rt-group-empty {
1292
+ margin: 10px 0 4px;
1293
+ font-size: 12.5px;
1294
+ color: var(--ink-faint);
1295
+ }
1296
+
1297
+ .rt-group-title {
1298
+ margin: 0;
1299
+ font-family: var(--font-display);
1300
+ font-size: 14.5px;
1301
+ font-weight: 600;
1302
+ letter-spacing: 0.01em;
1303
+ color: var(--ink-soft);
1304
+ }
1305
+
1306
+ /* 插件管理页的同名标题保留旧观感(独立于分组分区) */
1307
+ .rt-group-title.plugin-group-title {
1308
+ margin: 20px 0 2px;
1309
+ font-size: 15px;
1310
+ }
1311
+
1312
+ .rt-row {
1313
+ display: flex;
1314
+ align-items: center;
1315
+ justify-content: space-between;
1316
+ gap: 18px;
1317
+ padding: 11px 0;
1318
+ border-bottom: 1px solid var(--line);
1319
+ }
1320
+
1321
+ .rt-main {
1322
+ min-width: 0;
1323
+ }
1324
+
1325
+ .rt-title {
1326
+ display: flex;
1327
+ gap: 8px;
1328
+ align-items: center;
1329
+ font-size: 13.5px;
1330
+ font-weight: 500;
1331
+ color: var(--ink-soft);
1332
+ }
1333
+
1334
+ .rt-sub {
1335
+ max-width: 46ch;
1336
+ margin-top: 3px;
1337
+ font-size: 13px;
1338
+ line-height: 1.5;
1339
+ color: var(--ink-faint);
1340
+ }
1341
+
1342
+ .rt-tag {
1343
+ padding: 1px 7px;
1344
+ font-family: var(--font-mono);
1345
+ font-size: 11.5px;
1346
+ font-weight: 500;
1347
+ line-height: 1.5;
1348
+ color: var(--ink-faint);
1349
+ border: 1px solid var(--line-strong);
1350
+ border-radius: var(--radius-pill);
1351
+ }
1352
+
1353
+ .rt-tag.src-runtime {
1354
+ color: var(--accent);
1355
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1356
+ }
1357
+
1358
+ .rt-tag.src-env {
1359
+ color: #c9963f;
1360
+ border-color: rgb(201 150 63 / 45%);
1361
+ }
1362
+
1363
+ .rt-tag.live {
1364
+ color: var(--accent);
1365
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1366
+ }
1367
+
1368
+ .rt-tag.restart {
1369
+ color: #c9963f;
1370
+ border-color: rgb(201 150 63 / 45%);
1371
+ }
1372
+
1373
+ .rt-ctrl {
1374
+ display: flex;
1375
+ flex: none;
1376
+ gap: 8px;
1377
+ align-items: center;
1378
+ }
1379
+
1380
+ .rt-color {
1381
+ display: inline-flex;
1382
+ gap: 8px;
1383
+ align-items: center;
1384
+ }
1385
+
1386
+ .rt-color input[type='color'] {
1387
+ width: 36px;
1388
+ height: 32px;
1389
+ padding: 2px;
1390
+ cursor: pointer;
1391
+ background: var(--paper-raised);
1392
+ border: 1px solid var(--line-strong);
1393
+ border-radius: 6px;
1394
+ }
1395
+
1396
+ .rt-reset {
1397
+ padding: 4px 9px;
1398
+ font-size: 12px;
1399
+ }
1400
+
1401
+ .rt-actions {
1402
+ display: flex;
1403
+ gap: 10px;
1404
+ align-items: center;
1405
+ margin-top: 18px;
1406
+ }
1407
+
1408
+ .rt-path {
1409
+ margin-left: auto;
1410
+ font-size: 11.5px;
1411
+ color: var(--ink-faint);
1412
+ }
1413
+
1414
+ /* ============ 插件管理 ============ */
1415
+ .plugin-group-title {
1416
+ margin-top: 30px;
1417
+ padding-top: 22px;
1418
+ border-top: 1px solid var(--line);
1419
+ }
1420
+
1421
+ .plugin-row {
1422
+ display: flex;
1423
+ align-items: center;
1424
+ justify-content: space-between;
1425
+ gap: 18px;
1426
+ padding: 13px 0;
1427
+ border-bottom: 1px solid var(--line);
1428
+ transition: opacity var(--transition-fast);
1429
+ }
1430
+
1431
+ .plugin-row.off {
1432
+ opacity: 0.55;
1433
+ }
1434
+
1435
+ .plugin-main {
1436
+ min-width: 0;
1437
+ }
1438
+
1439
+ .plugin-health {
1440
+ display: inline-flex;
1441
+ gap: 5px;
1442
+ align-items: center;
1443
+ max-width: 320px;
1444
+ font-size: 12.5px;
1445
+ line-height: 1.4;
1446
+ }
1447
+
1448
+ .plugin-health.ok {
1449
+ color: #2e9e6b;
1450
+ }
1451
+
1452
+ .plugin-health.bad {
1453
+ color: #c2554a;
1454
+ }
1455
+
1456
+ .plugin-health.faint {
1457
+ color: var(--ink-faint);
1458
+ }
1459
+
1460
+ .plugin-health .spin {
1461
+ animation: plugin-spin 0.9s linear infinite;
1462
+ }
1463
+
1464
+ @keyframes plugin-spin {
1465
+ to { transform: rotate(360deg); }
1466
+ }
1467
+
1468
+ @media (prefers-reduced-motion: reduce) {
1469
+ .plugin-health .spin {
1470
+ animation: none;
1471
+ }
1472
+ }
1473
+
1474
+ @media (max-width: 768px) {
1475
+ .rt-row,
1476
+ .plugin-row {
1477
+ flex-direction: column;
1478
+ align-items: flex-start;
1479
+ gap: 10px;
1480
+ }
1481
+ .rt-actions {
1482
+ flex-wrap: wrap;
1483
+ }
1484
+ .rt-path {
1485
+ margin-left: 0;
1486
+ }
1487
+ }
1488
+
1489
+ /* ══ 窄屏(≤899px,对齐 useResponsive 的 drawer 档)═══════════════════════
1490
+ 桌面是"左栏菜单 180px + 右栏内容"的双栏仪表台。窄屏若仍并排,
1491
+ 内容区只剩 ~170px,描述文字被压成"一次一个字"的竖排长条(375px 实测)。
1492
+ 窄屏改为:菜单在上,收成一条可横扫的标签带;内容在下,占满整宽。
1493
+ 注:原先把这段堆叠规则误写在 prefers-reduced-motion 里 ——
1494
+ 那样只有"减少动效"的用户才看到正确布局,是反的,已移到宽度断点。 */
1495
+ @media (max-width: 899px) {
1496
+ .settings-layout {
1497
+ flex-direction: column;
1498
+ min-height: 0;
1499
+ }
1500
+
1501
+ .settings-nav {
1502
+ flex: none;
1503
+ flex-direction: row;
1504
+ gap: 6px;
1505
+ padding: 8px 10px;
1506
+ overflow-x: auto;
1507
+ overscroll-behavior-x: contain;
1508
+ border-right: 0;
1509
+ border-bottom: 1px solid var(--line);
1510
+ scrollbar-width: none;
1511
+ -webkit-overflow-scrolling: touch;
1512
+ }
1513
+
1514
+ .settings-nav::-webkit-scrollbar {
1515
+ display: none;
1516
+ }
1517
+
1518
+ .nav-item {
1519
+ flex: none;
1520
+ width: auto;
1521
+ min-height: 40px;
1522
+ padding: 8px 14px;
1523
+ white-space: nowrap;
1524
+ }
1525
+
1526
+ .settings-body {
1527
+ padding: 16px 12px;
1528
+ }
1529
+
1530
+ /* 卡片内边距在窄屏收边,把宽度让给内容 */
1531
+ .settings-card :deep(.ant-card-body) {
1532
+ padding: 12px;
1533
+ }
1534
+
1535
+ /* 正文说明文字窄屏抬到 13px 地板 */
1536
+ .set-sub,
1537
+ .rt-sub,
1538
+ .section-desc,
1539
+ .identity-note,
1540
+ .rt-group-empty,
1541
+ .plugin-health {
1542
+ font-size: 13px;
1543
+ }
1544
+
1545
+ .rt-group-desc {
1546
+ flex: 1 1 100%;
1547
+ }
1548
+
1549
+ /* 手指命中区:分组操作按钮只有 24px 高 */
1550
+ .rt-group-ops .mini-btn,
1551
+ .rt-group-head .mini-btn {
1552
+ min-width: 40px;
1553
+ min-height: 40px;
1554
+ padding: 6px 10px;
1555
+ }
1556
+ }
1557
+
1558
+ /* 无 hover 的设备(手机/平板):分组操作不能只在 hover 时才出现 */
1559
+ @media (hover: none) {
1560
+ .rt-group-ops {
1561
+ opacity: 1;
1562
+ }
1563
+ }
1564
+ </style>