agentworkshop 0.7.40 → 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 (327) 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/{DcsCcxp4.js → D98NH8kG.js} +1 -1
  11. package/.output/public/_nuxt/DKySmNPI.js +14 -0
  12. package/.output/public/_nuxt/Di0NY_ht.js +1 -0
  13. package/.output/public/_nuxt/Du9v6qGB.js +1 -0
  14. package/.output/public/_nuxt/{C_iYD-EB.js → EHlEMRii.js} +1 -1
  15. package/.output/public/_nuxt/Eke1fs-U.js +1 -0
  16. package/.output/public/_nuxt/{DeJykBFf.js → H0BcqZtT.js} +1 -1
  17. package/.output/public/_nuxt/{C4oW1HsE.js → HSYEkkce.js} +1 -1
  18. package/.output/public/_nuxt/{DZshyNLa.js → IREZ1eC2.js} +1 -1
  19. package/.output/public/_nuxt/{D_OTvQza.js → J9oU00D1.js} +1 -1
  20. package/.output/public/_nuxt/OmpTerminalPanel.BxR5-lvi.css +1 -0
  21. package/.output/public/_nuxt/SSyAPaHc.js +1 -0
  22. package/.output/public/_nuxt/TownView.li4EMQxK.css +1 -0
  23. package/.output/public/_nuxt/VdLk4jLE2.js +1 -0
  24. package/.output/public/_nuxt/_id_.BhsXoEuk.css +1 -0
  25. package/.output/public/_nuxt/_id_.DQyj-WKN.css +1 -0
  26. package/.output/public/_nuxt/{_wsId_.B4WiqkIS.css → _wsId_.CTx-BlfF.css} +1 -1
  27. package/.output/public/_nuxt/aShsv_rG.js +19 -0
  28. package/.output/public/_nuxt/{yF0aYMF1.js → aesgXKSr.js} +3 -3
  29. package/.output/public/_nuxt/agents.B21nUEUX.css +1 -0
  30. package/.output/public/_nuxt/aml.ekTjxFkf.css +1 -0
  31. package/.output/public/_nuxt/builds/latest.json +1 -1
  32. package/.output/public/_nuxt/builds/meta/b5506ef0-f48e-4f14-a6c0-d65e505d0b09.json +1 -0
  33. package/.output/public/_nuxt/ca4TJ3Op.js +1 -0
  34. package/.output/public/_nuxt/channel-templates.DplM1pJ3.css +1 -0
  35. package/.output/public/_nuxt/daq.BdVmoIfe.css +1 -0
  36. package/.output/public/_nuxt/dcw.DCgyzpWV.css +1 -0
  37. package/.output/public/_nuxt/{default.J32SgqFY.css → default.Bh3L2HU8.css} +1 -1
  38. package/.output/public/_nuxt/lHAxdSsj.js +1 -0
  39. package/.output/public/_nuxt/pages.j9rzRocr.css +1 -0
  40. package/.output/public/_nuxt/plugins.DhiZbHU8.css +1 -0
  41. package/.output/public/_nuxt/settings.BRx9rIDg.css +1 -0
  42. package/.output/public/_nuxt/teams.BLlOPB_Z.css +1 -0
  43. package/.output/public/_nuxt/tokens.De8xzszn.css +1 -0
  44. package/.output/public/_nuxt/workshop.D1jFNkrn.css +1 -0
  45. package/.output/server/chunks/_/nitro.mjs +1058 -830
  46. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  47. package/.output/server/chunks/build/{OmpTerminalPanel-Cu_s4i0A.mjs → OmpTerminalPanel-CVvxpsF7.mjs} +15 -15
  48. package/.output/server/chunks/build/{OmpTerminalPanel-Cu_s4i0A.mjs.map → OmpTerminalPanel-CVvxpsF7.mjs.map} +1 -1
  49. package/.output/server/chunks/build/{TownView-BuLAsmpR.mjs → TownView-CJ6IsDCc.mjs} +229 -228
  50. package/.output/server/chunks/build/{TownView-BuLAsmpR.mjs.map → TownView-CJ6IsDCc.mjs.map} +1 -1
  51. package/.output/server/chunks/build/{_id_-BU1wfOID.mjs → _id_-CVD_H0ir.mjs} +149 -147
  52. package/.output/server/chunks/build/_id_-CVD_H0ir.mjs.map +1 -0
  53. package/.output/server/chunks/build/{_id_-BYsEua57.mjs → _id_-CtxMb3qp.mjs} +47 -46
  54. package/.output/server/chunks/build/_id_-CtxMb3qp.mjs.map +1 -0
  55. package/.output/server/chunks/build/{_wsId_-BxlDcJHg.mjs → _wsId_-DpY3T-md.mjs} +516 -446
  56. package/.output/server/chunks/build/{_wsId_-BxlDcJHg.mjs.map → _wsId_-DpY3T-md.mjs.map} +1 -1
  57. package/.output/server/chunks/build/{agents-CLQqJF2y.mjs → agents-DsCkuXDV.mjs} +49 -46
  58. package/.output/server/chunks/build/agents-DsCkuXDV.mjs.map +1 -0
  59. package/.output/server/chunks/build/{aml-_7FdFL2p.mjs → aml-CT0xKGKI.mjs} +122 -105
  60. package/.output/server/chunks/build/aml-CT0xKGKI.mjs.map +1 -0
  61. package/.output/server/chunks/build/builtin-catalog-edpDcgfL.mjs +117 -0
  62. package/.output/server/chunks/build/builtin-catalog-edpDcgfL.mjs.map +1 -0
  63. package/.output/server/chunks/build/{channel-templates-p6SZNjlM.mjs → channel-templates-Dla9FbYa.mjs} +49 -37
  64. package/.output/server/chunks/build/channel-templates-Dla9FbYa.mjs.map +1 -0
  65. package/.output/server/chunks/build/{daq-cgyjKmJu.mjs → daq-DWWSKGAV.mjs} +95 -93
  66. package/.output/server/chunks/build/daq-DWWSKGAV.mjs.map +1 -0
  67. package/.output/server/chunks/build/{dcw-CoTxBlAZ.mjs → dcw-CT8k17_E.mjs} +46 -45
  68. package/.output/server/chunks/build/dcw-CT8k17_E.mjs.map +1 -0
  69. package/.output/server/chunks/build/{default-BE4r0FZN.mjs → default-D3525UFX.mjs} +16 -15
  70. package/.output/server/chunks/build/{default-BE4r0FZN.mjs.map → default-D3525UFX.mjs.map} +1 -1
  71. package/.output/server/chunks/build/{logs-BSOeZwwI.mjs → logs--dByNyhw.mjs} +2 -2
  72. package/.output/server/chunks/build/{logs-BSOeZwwI.mjs.map → logs--dByNyhw.mjs.map} +1 -1
  73. package/.output/server/chunks/build/{monitor-C0okQaXz.mjs → monitor-Dh2Kvaq5.mjs} +2 -2
  74. package/.output/server/chunks/build/{monitor-C0okQaXz.mjs.map → monitor-Dh2Kvaq5.mjs.map} +1 -1
  75. package/.output/server/chunks/build/{pages-DFxjn9Xa.mjs → pages-BHcSBM2D.mjs} +26 -20
  76. package/.output/server/chunks/build/pages-BHcSBM2D.mjs.map +1 -0
  77. package/.output/server/chunks/build/{plugins-lGlUr1lj.mjs → plugins-BNgIi61W.mjs} +23 -17
  78. package/.output/server/chunks/build/plugins-BNgIi61W.mjs.map +1 -0
  79. package/.output/server/chunks/build/{settings-Ddd1qx2Z.mjs → settings-ipYBDWdn.mjs} +45 -44
  80. package/.output/server/chunks/build/settings-ipYBDWdn.mjs.map +1 -0
  81. package/.output/server/chunks/build/{teams-MEDLZdMn.mjs → teams-5lx-CwFa.mjs} +47 -44
  82. package/.output/server/chunks/build/teams-5lx-CwFa.mjs.map +1 -0
  83. package/.output/server/chunks/build/{tokens-C7fTM5C2.mjs → tokens-q-IDT8Io.mjs} +73 -53
  84. package/.output/server/chunks/build/tokens-q-IDT8Io.mjs.map +1 -0
  85. package/.output/server/chunks/build/{town-qMPql9Se.mjs → town-QJWB5wng.mjs} +5 -4
  86. package/.output/server/chunks/build/town-QJWB5wng.mjs.map +1 -0
  87. package/.output/server/chunks/build/{useDaqStream-CIBaiGAl.mjs → useDaqStream-DfCFpb3p.mjs} +9 -3
  88. package/.output/server/chunks/build/useDaqStream-DfCFpb3p.mjs.map +1 -0
  89. package/.output/server/chunks/build/{useDcwStream-CBbiYnWe.mjs → useDcwStream-UPiX-3iO.mjs} +11 -2
  90. package/.output/server/chunks/build/useDcwStream-UPiX-3iO.mjs.map +1 -0
  91. package/.output/server/chunks/build/{workshop-BL5bhfs0.mjs → workshop-BIlCj_ZP.mjs} +45 -45
  92. package/.output/server/chunks/build/workshop-BIlCj_ZP.mjs.map +1 -0
  93. package/.output/server/chunks/routes/api/workshop/channel-templates/_id/instantiate.post.mjs +1 -1
  94. package/.output/server/chunks/routes/api/workshop/channels.post.mjs +3 -3
  95. package/.output/server/chunks/routes/api/workshop/dcw/index5.post.mjs +1 -1
  96. package/.output/server/chunks/routes/api/workshop/dcw/lines/_id/start.post.mjs +1 -1
  97. package/.output/server/chunks/routes/api/workshop/dcw/lines/_id/stop.post.mjs +1 -1
  98. package/.output/server/chunks/routes/api/workshop/dcw/params/_id_.delete.mjs +1 -1
  99. package/.output/server/chunks/routes/api/workshop/dcw/params/_id_.patch.mjs +1 -1
  100. package/.output/server/chunks/routes/api/workshop/dcw/templates/_key_.delete.mjs +1 -1
  101. package/.output/server/chunks/routes/api/workshop/dcw/templates/_key_.patch.mjs +1 -1
  102. package/.output/server/chunks/routes/api/workshop/harnesses/_id/providers.get.mjs +1 -1
  103. package/.output/server/chunks/routes/api/workshop/harnesses.get.mjs +1 -1
  104. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs +1 -1
  105. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs +1 -1
  106. package/.output/server/chunks/routes/api/workshop/index.put.mjs +1 -1
  107. package/.output/server/chunks/routes/api/workshop/index5.get.mjs +3 -1
  108. package/.output/server/chunks/routes/api/workshop/index5.get.mjs.map +1 -1
  109. package/.output/server/chunks/routes/api/workshop/index6.get.mjs +3 -1
  110. package/.output/server/chunks/routes/api/workshop/index6.get.mjs.map +1 -1
  111. package/.output/server/chunks/routes/api/workshop/plugins/_name/disable.post.mjs +1 -1
  112. package/.output/server/chunks/routes/api/workshop/plugins/_name/enable.post.mjs +1 -1
  113. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.delete.mjs +1 -1
  114. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.put.mjs +1 -1
  115. package/.output/server/chunks/routes/api/workshop/workspaces/_id/channel-templates/_tplId_.post.mjs +1 -1
  116. package/.output/server/chunks/routes/renderer.mjs +2 -2
  117. package/.output/server/chunks/virtual/entry.mjs +639 -88
  118. package/.output/server/chunks/virtual/entry.mjs.map +1 -1
  119. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  120. package/.output/server/package.json +1 -1
  121. package/README-zh.md +16 -6
  122. package/README.md +17 -7
  123. package/app/components/AppHeader.vue +961 -959
  124. package/app/components/workshop/A2aRpcDebugger.vue +237 -237
  125. package/app/components/workshop/AgentInspectorDrawer.vue +424 -424
  126. package/app/components/workshop/AgentLanesView.vue +998 -998
  127. package/app/components/workshop/ArtifactCard.vue +120 -120
  128. package/app/components/workshop/AssetLibrary.vue +452 -452
  129. package/app/components/workshop/ChannelSessionList.vue +812 -804
  130. package/app/components/workshop/CommandPalette.vue +269 -269
  131. package/app/components/workshop/Composer.vue +827 -827
  132. package/app/components/workshop/DeviceTwinPanel.vue +643 -643
  133. package/app/components/workshop/EventBlock.vue +602 -602
  134. package/app/components/workshop/FileSelectorModal.vue +188 -188
  135. package/app/components/workshop/InspectorPanel.vue +301 -301
  136. package/app/components/workshop/MemoryPanel.vue +364 -364
  137. package/app/components/workshop/MultiChannelView.vue +189 -189
  138. package/app/components/workshop/TaskBoardView.vue +686 -686
  139. package/app/components/workshop/TaskInspectorDrawer.vue +390 -390
  140. package/app/components/workshop/TranscriptTimeline.vue +601 -601
  141. package/app/components/workshop/blocks/ClusterArtifact.vue +61 -61
  142. package/app/components/workshop/blocks/ClusterError.vue +85 -85
  143. package/app/components/workshop/blocks/ClusterLife.vue +87 -87
  144. package/app/components/workshop/blocks/ClusterMember.vue +95 -93
  145. package/app/components/workshop/blocks/ClusterMemory.vue +84 -84
  146. package/app/components/workshop/blocks/ClusterOther.vue +67 -67
  147. package/app/components/workshop/blocks/ClusterStatus.vue +149 -147
  148. package/app/components/workshop/blocks/ClusterTool.vue +158 -158
  149. package/app/components/workshop/terminal/OmpTerminalPanel.vue +1011 -1011
  150. package/app/components/workshop/town/TownView.vue +56 -56
  151. package/app/composables/workshop/useDaqStream.ts +7 -4
  152. package/app/composables/workshop/useDcwStream.ts +8 -2
  153. package/app/middleware/locale-cookie.global.ts +22 -0
  154. package/app/pages/aml/index.vue +24 -2
  155. package/app/pages/daq/[id].vue +20 -16
  156. package/app/pages/daq/index.vue +2310 -2307
  157. package/app/pages/dcw/[id].vue +26 -23
  158. package/app/pages/dcw/index.vue +3 -3
  159. package/app/pages/index.vue +987 -981
  160. package/app/pages/plugins/index.vue +317 -311
  161. package/app/pages/settings.vue +1564 -1562
  162. package/app/pages/tokens.vue +15 -15
  163. package/app/pages/workshop/agents.vue +612 -609
  164. package/app/pages/workshop/channel-templates.vue +18 -11
  165. package/app/pages/workshop/index.vue +668 -668
  166. package/app/pages/workshop/teams.vue +743 -740
  167. package/app/pages/workshop/w/[wsId].vue +704 -704
  168. package/app/plugins/locale-restore.client.ts +16 -1
  169. package/app/utils/builtin-catalog.ts +104 -0
  170. package/config.yml +170 -170
  171. package/docs/cli.en.md +2 -2
  172. package/docs/cli.md +2 -2
  173. package/docs/full-test-plan.md +2 -3
  174. package/docs/plugins.en.md +33 -1
  175. package/docs/plugins.md +29 -1
  176. package/docs/sdk.en.md +1 -1
  177. package/docs/sdk.md +1 -1
  178. package/i18n/locales/en.ts +293 -35
  179. package/i18n/locales/zh-CN.ts +265 -7
  180. package/package.json +4 -3
  181. package/scripts/_dbg-perms-e2e.mjs +132 -109
  182. package/scripts/_demo-agentteam-capture.mjs +278 -0
  183. package/scripts/_e2e-ui-functional.mjs +371 -0
  184. package/scripts/_i18n-en-cjk-scan.mjs +28 -0
  185. package/scripts/_i18n-en-verify.mjs +107 -0
  186. package/scripts/_i18n-extra-checks.mjs +53 -0
  187. package/scripts/_i18n-scan-hardcoded.mjs +180 -0
  188. package/scripts/_i18n-verify-keys.mjs +50 -0
  189. package/scripts/_loop-biax-campaign.mjs +130 -0
  190. package/scripts/_sweep-button-contrast.mjs +87 -0
  191. package/scripts/test-serial-bridge.mjs +418 -0
  192. package/scripts/ui/e2e-interactive.mjs +328 -0
  193. package/scripts/ui/record-demo.mjs +557 -0
  194. package/scripts/ui/seed-industrial-demo.mjs +171 -0
  195. package/server/api/workshop/daq/index.get.ts +3 -1
  196. package/server/api/workshop/dcw/index.get.ts +3 -0
  197. package/server/plugins/aw-plugins.ts +9 -7
  198. package/server/plugins-builtin/diag-bridge/index.mjs +608 -661
  199. package/server/plugins-builtin/rag-bridge/index.mjs +57 -133
  200. package/server/plugins-builtin/serial-bridge/client.mjs +210 -0
  201. package/server/plugins-builtin/serial-bridge/drivers.mjs +409 -0
  202. package/server/plugins-builtin/serial-bridge/i18n.json +62 -0
  203. package/server/plugins-builtin/serial-bridge/index.mjs +247 -0
  204. package/server/plugins-builtin/serial-bridge/lib.mjs +208 -0
  205. package/server/plugins-builtin/serial-bridge/rtu.mjs +168 -0
  206. package/server/services/workshop/daq/daq-controller.ts +7 -2
  207. package/server/services/workshop/daq/drivers.ts +89 -3
  208. package/server/services/workshop/dcw/dcw-controller.ts +5 -1
  209. package/server/services/workshop/dcw/drivers.ts +85 -2
  210. package/server/services/workshop/dcw/plugin-bridge.ts +45 -0
  211. package/server/services/workshop/plugins/host.mjs +30 -0
  212. package/shared/daq-protocol.ts +15 -0
  213. package/.output/public/_nuxt/88GOowgO2.js +0 -1
  214. package/.output/public/_nuxt/BOt2SP4z.js +0 -1
  215. package/.output/public/_nuxt/BP-NyKVG.js +0 -18
  216. package/.output/public/_nuxt/BXxH0UQE.js +0 -1
  217. package/.output/public/_nuxt/BsROmY89.js +0 -1
  218. package/.output/public/_nuxt/ClILjvWH.js +0 -1
  219. package/.output/public/_nuxt/CzOCSGLe.js +0 -1
  220. package/.output/public/_nuxt/D8qvkfyY.js +0 -1
  221. package/.output/public/_nuxt/DCn7QZ5N.js +0 -2
  222. package/.output/public/_nuxt/DS8qj8EL.js +0 -1
  223. package/.output/public/_nuxt/DUjW5Yq5.js +0 -1
  224. package/.output/public/_nuxt/Dihnimlk.js +0 -19
  225. package/.output/public/_nuxt/Dqw1lpr-.js +0 -1
  226. package/.output/public/_nuxt/Dr9v6C0f.js +0 -14
  227. package/.output/public/_nuxt/Du9ZJtnj.js +0 -1
  228. package/.output/public/_nuxt/OmpTerminalPanel.B_Pl5Ynm.css +0 -1
  229. package/.output/public/_nuxt/TownView.f-YOdXRs.css +0 -1
  230. package/.output/public/_nuxt/_id_.CM4bIgKY.css +0 -1
  231. package/.output/public/_nuxt/_id_.bNV69Dtf.css +0 -1
  232. package/.output/public/_nuxt/agents.ZD0rVdHE.css +0 -1
  233. package/.output/public/_nuxt/aml.BN8ZEW4h.css +0 -1
  234. package/.output/public/_nuxt/builds/meta/f4707884-7cf4-4a31-80d2-e3d72b73745f.json +0 -1
  235. package/.output/public/_nuxt/channel-templates.BOtahzPi.css +0 -1
  236. package/.output/public/_nuxt/daq.B0D3dPAc.css +0 -1
  237. package/.output/public/_nuxt/dcw.8Y7FqCVN.css +0 -1
  238. package/.output/public/_nuxt/pages.D7aSDlUI.css +0 -1
  239. package/.output/public/_nuxt/plugins.sb4nDGqr.css +0 -1
  240. package/.output/public/_nuxt/qr6R6Ha1.js +0 -1
  241. package/.output/public/_nuxt/settings.BTgR-NiE.css +0 -1
  242. package/.output/public/_nuxt/teams.CxnxKVVs.css +0 -1
  243. package/.output/public/_nuxt/tokens.BFY7qv4f.css +0 -1
  244. package/.output/public/_nuxt/workshop.CR_QzeAN.css +0 -1
  245. package/.output/server/chunks/build/_id_-BU1wfOID.mjs.map +0 -1
  246. package/.output/server/chunks/build/_id_-BYsEua57.mjs.map +0 -1
  247. package/.output/server/chunks/build/agents-CLQqJF2y.mjs.map +0 -1
  248. package/.output/server/chunks/build/aml-_7FdFL2p.mjs.map +0 -1
  249. package/.output/server/chunks/build/channel-templates-p6SZNjlM.mjs.map +0 -1
  250. package/.output/server/chunks/build/daq-cgyjKmJu.mjs.map +0 -1
  251. package/.output/server/chunks/build/dcw-CoTxBlAZ.mjs.map +0 -1
  252. package/.output/server/chunks/build/pages-DFxjn9Xa.mjs.map +0 -1
  253. package/.output/server/chunks/build/plugins-lGlUr1lj.mjs.map +0 -1
  254. package/.output/server/chunks/build/settings-Ddd1qx2Z.mjs.map +0 -1
  255. package/.output/server/chunks/build/teams-MEDLZdMn.mjs.map +0 -1
  256. package/.output/server/chunks/build/tokens-C7fTM5C2.mjs.map +0 -1
  257. package/.output/server/chunks/build/town-qMPql9Se.mjs.map +0 -1
  258. package/.output/server/chunks/build/useDaqStream-CIBaiGAl.mjs.map +0 -1
  259. package/.output/server/chunks/build/useDcwStream-CBbiYnWe.mjs.map +0 -1
  260. package/.output/server/chunks/build/workshop-BL5bhfs0.mjs.map +0 -1
  261. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.continue.json +0 -1
  262. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.json +0 -1
  263. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/054bb4bc4e4217a2ab0b0c6b663747214a9af464f5568ea60e784704e340bb0f.source +0 -153
  264. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/0554197c3d2bac203a1f5c48a5d52495d1336db1bd7aa4f4c19875810363e684.source +0 -153
  265. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/05c203d12a954fffba942e1d626e3f5fec05b18a5aef2bac003b7884a4b77406.source +0 -431
  266. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/145a6e6ba811af35aed31e0cc3211f53706a14980fa069ee9e3c1591139f96f0.source +0 -180
  267. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/1ae0fb281f97899c025e368ca73f41349b01fb5f85b19e08d7ebccff49095ed5.source +0 -112
  268. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/20ee9967aa0541c516db7575bdd4a1a745939bc7016bd10d7635903113edb8ec.source +0 -227
  269. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/233751843aeba9d4c5263f275b8102de00443c06f286918cbd23e0b6729e01aa.source +0 -131
  270. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/263c2e05b425e5c322e60c6475279f963bab3a873eb521b21df7fec935a46597.source +0 -160
  271. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/2ac5eaac28eaaee2178c4b32014df8e732f9dd432a285a872f5b7f31c1395fa4.source +0 -157
  272. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/33a0db0d2d95d2c11d0720090458e5fba1425376f5ceb5b422d1d3471be3226b.source +0 -348
  273. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/359e474ae83db35e4615d3728de6bf694b64d4dce9b536730a3b6518f9c0494a.source +0 -296
  274. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/37a4c3c31c4b70f6b719c53d1e9c4fcba603f31adfdd11bb6ef9f0d094923c56.source +0 -285
  275. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/461feca1c14a695eb8b0f17a6f95fe97eeceabc0510faaea4ba6d25bde147d8d.source +0 -217
  276. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/4cfadbda8591f29d5e1e230dc000e701b756e850441b3900fb596a4d2f6e98c2.source +0 -301
  277. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/50ca2810cf0b248c96948994c0c404531bbad6e553166b6816b3b03bfa0f75d2.source +0 -151
  278. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/52d9e8cb591a9ae86e238c3620a610cd386717bee222c0f37b25b2ac7e523dc4.source +0 -135
  279. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/6c87daafa4c006560a954636a97333f6123153c9d34d96203f7ee3a14e556357.source +0 -156
  280. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/77bc47854b577900621cacf1697f81fa2bc005af129d37614a97a3441c08f99f.source +0 -72
  281. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/7bbb789d3a527817118cf8399ed1861d6a962ade10878fd07223435d333a7089.source +0 -469
  282. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/86b7c1daafc8b60ba9982dde5bf1bcc74265709483c176e71f23ec6fc267451f.source +0 -259
  283. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/8df66d62bd41984d0970e8236882080c5085ac937d07a29779463d6a03b37c76.source +0 -97
  284. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/9962f2d1b3a85137bd40cc835725600b39f89ee02a27c06b2e0402e1eafde115.source +0 -277
  285. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a1090ea066b792cb201739f4994e37bf7c12f52e805239a1eab87f18a537f266.source +0 -171
  286. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a5241091650134d0919897e086a606a3846c8b2b54f8b4c84a8696c145ac3e9e.source +0 -425
  287. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/af19fa4c6e34d5540028be4b17600161283af3a2a2dfc61a3fdc0834436a0c14.source +0 -99
  288. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3713d78e2b2f93905ff6be8d58a8d0a2f42dea322eb67c987fda22ff6a084d5.source +0 -325
  289. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3849943c3aff102d1af815631fc843a2a52c17646a4b04696ece323e483bbec.source +0 -332
  290. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/bac735a3803a6650b7f1eb8f587e6dc5c68ea055000c3c99a11789438b78e837.source +0 -43
  291. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d32c24d61eb8b33c73e5a8fc7e4dc7ef437aed238ee4371c2b00617e6104090a.source +0 -154
  292. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d5170dc9a9879fda04fdf91d8a110d4efb6787ee38dda596d4da899883d36a72.source +0 -531
  293. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/dc59bcd7bb397be63ba2f2c3fec23f3782dcb17d2d75a9981eeced761f33eb32.source +0 -439
  294. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/eb5ff8cf66b92aec7bf6726781d8df74faa2b030c3aaac17d6b00c1fc04c9b32.source +0 -323
  295. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f10615a6fae18436e70d1201b2d44fdae3174a3aa7bb4db97dea1b3ed291d721.source +0 -433
  296. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f36d3e271668bc1260c2823542d22dbf1d0f7041ff16b1ac175bce2256e582ca.source +0 -280
  297. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f94449380af09f31ac352152007146a75cc80bae8d6411ffe57b2c56f933b0ff.source +0 -117
  298. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/fe4dfd81178fd6b648335f6405d9ede71319aebde796008e50db21f8adf44c99.source +0 -160
  299. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/ffb4e3aa548cfdadabaa44ab599f9b6a3ca6288176a2cc7a4b2fc2a12b5fb848.source +0 -101
  300. package/scripts/.mimosa/hook-status/sess_aeaecb58-8833-4b83-b93c-050467d21e03-d944d71bb3.json +0 -14
  301. package/scripts/_dbg-audit-all-pages.mjs +0 -43
  302. package/scripts/_dbg-daq-frames-e2e.mjs +0 -118
  303. package/scripts/_dbg-daq-rowstyle-verify.mjs +0 -112
  304. package/scripts/_dbg-hitl-timeout-e2e.mjs +0 -93
  305. package/scripts/_dbg-live-line-e2e.mjs +0 -279
  306. package/scripts/_dbg-minimal-omp.mjs +0 -77
  307. package/scripts/_dbg-mode-nav.mjs +0 -173
  308. package/scripts/_dbg-real-scenario-timeline.log +0 -55
  309. package/scripts/_dbg-team-repro.mjs +0 -76
  310. package/scripts/_dbg-town-agents-probe.mjs +0 -60
  311. package/scripts/_dbg-visual-acceptance.mjs +0 -63
  312. package/scripts/_townview_style.css +0 -1185
  313. package/scripts/_townview_template.html +0 -1215
  314. package/server/data/agent-node-bindings.json +0 -554
  315. package/server/data/daq-templates.json +0 -16
  316. package/server/data/daqs.json +0 -7464
  317. package/server/data/dcw-lines.json +0 -1787
  318. package/server/data/dcw-products.json +0 -1626
  319. package/server/data/dcw-recipes.json +0 -4672
  320. package/server/data/dcw-rollback.json +0 -20754
  321. package/server/data/dcw-runs.json +0 -6354
  322. package/server/data/dcw-templates.json +0 -146
  323. package/server/data/dcw-writes.json +0 -15650
  324. package/server/data/dcws.json +0 -9033
  325. package/server/data/device-twins.json +0 -133
  326. package/server/data/line-runs.json +0 -322
  327. 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>