]> acesimba.cloud Git - StationBuilder.git/commitdiff
Vue3 移植版 StationBuilder 全量页面与部署配置
authorCodeBuddy <codebuddy@local>
Tue, 22 Sep 2026 22:36:47 +0000 (06:36 +0800)
committerCodeBuddy <codebuddy@local>
Tue, 22 Sep 2026 22:36:47 +0000 (06:36 +0800)
- 在 src-vue 下完成 Vue3 + ant-design-vue 对 React 版 StationBuilder 的全量移植
  (站点管理/基本信息/资产/页面配置/菜单配置/生成/结果/预览/领域方案/
  样式库/设备模板/内部工具/领域配置 等全部页面与向导、API 接线)
- 抽取跨文件共用的 DeviceTemplateCard 到 src-vue/components/DeviceTemplateCard.js
- Vue 使用独立 root(src-vue) 与 HTML 入口,与 React 的 index.html 解耦,避免影响 React 构建
- vite.local.config.js: base /t/stationbuilder-vue,outDir 固定 dist-vue
- 部署:新增 stationbuilder-vue.service(vite preview :37834)、nginx /t/stationbuilder-vue*
  门禁反代(任务 stationbuilder-vue)、tasks.json 新增可管理任务
- .gitignore 忽略 dist-vue 构建产物;不提交运行时 DB 数据

86 files changed:
.gitignore
package-lock.json
package.json
src-vue/App.vue [new file with mode: 0644]
src-vue/Home.vue [new file with mode: 0644]
src-vue/components/Dashboard.vue [new file with mode: 0644]
src-vue/components/DashboardThumb.vue [new file with mode: 0644]
src-vue/components/DeviceTemplateCard.js [new file with mode: 0644]
src-vue/components/DomainPageThumb.vue [new file with mode: 0644]
src-vue/components/DropdownButton.vue [new file with mode: 0644]
src-vue/components/Field.vue [new file with mode: 0644]
src-vue/components/ImagePage.vue [new file with mode: 0644]
src-vue/components/Login.vue [new file with mode: 0644]
src-vue/components/Modal.vue [new file with mode: 0644]
src-vue/components/Pagination.vue [new file with mode: 0644]
src-vue/components/Panel.vue [new file with mode: 0644]
src-vue/components/PrimaryButton.vue [new file with mode: 0644]
src-vue/components/ResourcePlaceholder.vue [new file with mode: 0644]
src-vue/components/SearchInput.vue [new file with mode: 0644]
src-vue/components/SideMenu.vue [new file with mode: 0644]
src-vue/components/SiteEditTabs.vue [new file with mode: 0644]
src-vue/components/StatusTag.vue [new file with mode: 0644]
src-vue/components/Steps.vue [new file with mode: 0644]
src-vue/components/SvgIcon.vue [new file with mode: 0644]
src-vue/components/dropdown-button.less [new file with mode: 0644]
src-vue/components/header.less [new file with mode: 0644]
src-vue/components/menu.less [new file with mode: 0644]
src-vue/components/pagination.less [new file with mode: 0644]
src-vue/components/search-input.less [new file with mode: 0644]
src-vue/index.html [new file with mode: 0644]
src-vue/lib/api.js [new file with mode: 0644]
src-vue/lib/meta.js [new file with mode: 0644]
src-vue/main.js [new file with mode: 0644]
src-vue/pages/Assets.vue [new file with mode: 0644]
src-vue/pages/Basic.vue [new file with mode: 0644]
src-vue/pages/Dashboard.vue [new file with mode: 0644]
src-vue/pages/DeviceTemplates.vue [new file with mode: 0644]
src-vue/pages/DomainSettings.vue [new file with mode: 0644]
src-vue/pages/Functions.vue [new file with mode: 0644]
src-vue/pages/Generate.vue [new file with mode: 0644]
src-vue/pages/ManualAssetConfiguration.vue [new file with mode: 0644]
src-vue/pages/PageConfig.vue [new file with mode: 0644]
src-vue/pages/Placeholder.vue [new file with mode: 0644]
src-vue/pages/Preview.vue [new file with mode: 0644]
src-vue/pages/Projects.vue [new file with mode: 0644]
src-vue/pages/Result.vue [new file with mode: 0644]
src-vue/pages/SiteConfigurationContext.vue [new file with mode: 0644]
src-vue/pages/StyleLibrary.vue [new file with mode: 0644]
src-vue/pages/ToolSettings.vue [new file with mode: 0644]
src-vue/pages/domain/CompanyDomainLibrary.vue [new file with mode: 0644]
src-vue/pages/domain/DeviceImageField.vue [new file with mode: 0644]
src-vue/pages/domain/DeviceTemplateCreate.vue [new file with mode: 0644]
src-vue/pages/domain/DomainCapabilityDetail.vue [new file with mode: 0644]
src-vue/pages/domain/DomainDetail.vue [new file with mode: 0644]
src-vue/pages/domain/DomainFunctionsStep.vue [new file with mode: 0644]
src-vue/pages/domain/DomainGenerateReview.vue [new file with mode: 0644]
src-vue/pages/domain/DomainKnowledgeStep.vue [new file with mode: 0644]
src-vue/pages/domain/DomainPagePreviewModal.vue [new file with mode: 0644]
src-vue/pages/domain/DomainSteps.vue [new file with mode: 0644]
src-vue/pages/domain/DomainStyleLibrary.vue [new file with mode: 0644]
src-vue/pages/domain/DomainWizard.vue [new file with mode: 0644]
src-vue/pages/domain/ImportDomainPackage.vue [new file with mode: 0644]
src-vue/pages/domain/PackageModal.vue [new file with mode: 0644]
src-vue/pages/domain/StyleEditorModal.vue [new file with mode: 0644]
src-vue/pages/domain/StylePreview.vue [new file with mode: 0644]
src-vue/pages/domain/domainCommon.js [new file with mode: 0644]
src-vue/pages/domain/useConfirm.js [new file with mode: 0644]
src-vue/site-menu-model.js [new file with mode: 0644]
src-vue/styles/antd.less [new file with mode: 0644]
src-vue/styles/brand-layout.css [new file with mode: 0644]
src-vue/styles/globals.css [new file with mode: 0644]
src-vue/styles/site-workspace.css [new file with mode: 0644]
src-vue/styles/styles.less [new file with mode: 0644]
src-vue/styles/theme-overrides.css [new file with mode: 0644]
src-vue/styles/theme.json [new file with mode: 0644]
src-vue/styles/ui-layout-compat.css [new file with mode: 0644]
src-vue/styles/ui-system.css [new file with mode: 0644]
src-vue/ui/UiActions.vue [new file with mode: 0644]
src-vue/ui/UiButton.vue [new file with mode: 0644]
src-vue/ui/UiInput.vue [new file with mode: 0644]
src-vue/ui/UiOverlay.vue [new file with mode: 0644]
src-vue/ui/UiSelect.vue [new file with mode: 0644]
src-vue/ui/UiTabbedRegion.vue [new file with mode: 0644]
src-vue/ui/UiTable.vue [new file with mode: 0644]
src-vue/ui/parse.js [new file with mode: 0644]
vite.local.config.js [new file with mode: 0644]

index a92000265e2156e4d48368c27e3d5d7e85d57403..f3599b96b4e8ddf0886ed4f22070a8810d17b8de 100644 (file)
@@ -37,6 +37,8 @@ yarn-error.log*
 next-env.d.ts
 tsconfig.tsbuildinfo
 /dist/
+/dist-vue/
+/src-vue/dist-vue/
 /.wrangler/
 /.sites-runtime/
 /outputs/
index b3b83cce3df1fee5e9ed918673a48a08b903244c..462ab2dac4cb98a7f23de5dca668e2508cdb5f2b 100644 (file)
@@ -9,6 +9,8 @@
       "version": "0.1.0",
       "dependencies": {
         "@ant-design/icons": "^6.0.0",
+        "@vitejs/plugin-vue": "^6.0.9",
+        "ant-design-vue": "^4.2.6",
         "antd": "^6.6.4",
         "drizzle-orm": "0.45.2",
         "mammoth": "^1.12.1",
@@ -17,6 +19,7 @@
         "react": "19.2.6",
         "react-dom": "19.2.6",
         "sharp": "0.34.5",
+        "vue": "^3.5.43",
         "xlsx": "^0.18.5"
       },
       "devDependencies": {
       "integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==",
       "license": "MIT"
     },
+    "node_modules/@ant-design/icons-vue": {
+      "version": "7.0.1",
+      "resolved": "http://mirrors.tencent.com/npm/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
+      "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-svg": "^4.2.1"
+      },
+      "peerDependencies": {
+        "vue": ">=3.0.3"
+      }
+    },
+    "node_modules/@ant-design/icons-vue/node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "http://mirrors.tencent.com/npm/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
     "node_modules/@ant-design/react-slick": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
       }
     },
     "node_modules/@babel/helper-string-parser": {
-      "version": "7.27.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
-      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
-      "dev": true,
+      "version": "7.29.7",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
       "license": "MIT",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.28.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
-      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
-      "dev": true,
+      "version": "7.29.7",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
       "license": "MIT",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.29.3",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
-      "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
-      "dev": true,
-      "license": "MIT",
+      "version": "7.29.9",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/parser/-/parser-7.29.9.tgz",
+      "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
       "dependencies": {
-        "@babel/types": "^7.29.0"
+        "@babel/types": "^7.29.8"
       },
       "bin": {
         "parser": "bin/babel-parser.js"
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.29.0",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
-      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
-      "dev": true,
+      "version": "7.29.8",
+      "resolved": "http://mirrors.tencent.com/npm/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-string-parser": "^7.27.1",
-        "@babel/helper-validator-identifier": "^7.28.5"
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
       },
       "engines": {
         "node": ">=6.9.0"
         "@jridgewell/sourcemap-codec": "^1.4.10"
       }
     },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.1",
+      "resolved": "http://mirrors.tencent.com/npm/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/@drizzle-team/brocli": {
       "version": "0.10.2",
       "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
         "node": ">=6.0.0"
       }
     },
-    "node_modules/@jridgewell/source-map": {
-      "version": "0.3.11",
-      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
-      "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jridgewell/gen-mapping": "^0.3.5",
-        "@jridgewell/trace-mapping": "^0.3.25"
-      }
-    },
     "node_modules/@jridgewell/sourcemap-codec": {
       "version": "1.5.5",
       "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
       "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@jridgewell/trace-mapping": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
       "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@rollup/pluginutils": {
         "node": ">= 8.0.0"
       }
     },
+    "node_modules/@simonwep/pickr": {
+      "version": "1.8.2",
+      "resolved": "http://mirrors.tencent.com/npm/@simonwep/pickr/-/pickr-1.8.2.tgz",
+      "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-js": "^3.15.1",
+        "nanopop": "^2.1.0"
+      }
+    },
     "node_modules/@sindresorhus/is": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
         "tslib": "^2.4.0"
       }
     },
-    "node_modules/@types/eslint": {
-      "version": "9.6.1",
-      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
-      "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/estree": "*",
-        "@types/json-schema": "*"
-      }
-    },
-    "node_modules/@types/eslint-scope": {
-      "version": "3.7.7",
-      "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
-      "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/eslint": "*",
-        "@types/estree": "*"
-      }
-    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@webassemblyjs/ast": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
-      "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
-      "dev": true,
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.9",
+      "resolved": "http://mirrors.tencent.com/npm/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
+      "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/helper-numbers": "1.13.2",
-        "@webassemblyjs/helper-wasm-bytecode": "1.13.2"
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
       }
     },
-    "node_modules/@webassemblyjs/floating-point-hex-parser": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
-      "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/@webassemblyjs/helper-api-error": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
-      "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/@webassemblyjs/helper-buffer": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
-      "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/@webassemblyjs/helper-numbers": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
-      "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
-      "dev": true,
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
+      "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/floating-point-hex-parser": "1.13.2",
-        "@webassemblyjs/helper-api-error": "1.13.2",
-        "@xtuc/long": "4.2.2"
+        "@babel/parser": "^7.29.8",
+        "@vue/shared": "3.5.43",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
       }
     },
-    "node_modules/@webassemblyjs/helper-wasm-bytecode": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
-      "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
-    "node_modules/@webassemblyjs/helper-wasm-section": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
-      "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@webassemblyjs/ast": "1.14.1",
-        "@webassemblyjs/helper-buffer": "1.14.1",
-        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
-        "@webassemblyjs/wasm-gen": "1.14.1"
+    "node_modules/@vue/compiler-core/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "http://mirrors.tencent.com/npm/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
       }
     },
-    "node_modules/@webassemblyjs/ieee754": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
-      "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
-      "dev": true,
+    "node_modules/@vue/compiler-core/node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "http://mirrors.tencent.com/npm/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
+      "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@xtuc/ieee754": "^1.2.0"
+        "@vue/compiler-core": "3.5.43",
+        "@vue/shared": "3.5.43"
       }
     },
-    "node_modules/@webassemblyjs/leb128": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
-      "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "peer": true,
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
+      "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
+      "license": "MIT",
       "dependencies": {
-        "@xtuc/long": "4.2.2"
+        "@babel/parser": "^7.29.8",
+        "@vue/compiler-core": "3.5.43",
+        "@vue/compiler-dom": "3.5.43",
+        "@vue/compiler-ssr": "3.5.43",
+        "@vue/shared": "3.5.43",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.28",
+        "source-map-js": "^1.2.1"
       }
     },
-    "node_modules/@webassemblyjs/utf8": {
-      "version": "1.13.2",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
-      "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
+    "node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "http://mirrors.tencent.com/npm/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
     },
-    "node_modules/@webassemblyjs/wasm-edit": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
-      "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
-      "dev": true,
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
+      "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/ast": "1.14.1",
-        "@webassemblyjs/helper-buffer": "1.14.1",
-        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
-        "@webassemblyjs/helper-wasm-section": "1.14.1",
-        "@webassemblyjs/wasm-gen": "1.14.1",
-        "@webassemblyjs/wasm-opt": "1.14.1",
-        "@webassemblyjs/wasm-parser": "1.14.1",
-        "@webassemblyjs/wast-printer": "1.14.1"
+        "@vue/compiler-dom": "3.5.43",
+        "@vue/shared": "3.5.43"
       }
     },
-    "node_modules/@webassemblyjs/wasm-gen": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
-      "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
-      "dev": true,
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/reactivity/-/reactivity-3.5.43.tgz",
+      "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/ast": "1.14.1",
-        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
-        "@webassemblyjs/ieee754": "1.13.2",
-        "@webassemblyjs/leb128": "1.13.2",
-        "@webassemblyjs/utf8": "1.13.2"
+        "@vue/shared": "3.5.43"
       }
     },
-    "node_modules/@webassemblyjs/wasm-opt": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
-      "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
-      "dev": true,
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
+      "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/ast": "1.14.1",
-        "@webassemblyjs/helper-buffer": "1.14.1",
-        "@webassemblyjs/wasm-gen": "1.14.1",
-        "@webassemblyjs/wasm-parser": "1.14.1"
+        "@vue/reactivity": "3.5.43",
+        "@vue/shared": "3.5.43"
       }
     },
-    "node_modules/@webassemblyjs/wasm-parser": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
-      "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
-      "dev": true,
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
+      "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/ast": "1.14.1",
-        "@webassemblyjs/helper-api-error": "1.13.2",
-        "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
-        "@webassemblyjs/ieee754": "1.13.2",
-        "@webassemblyjs/leb128": "1.13.2",
-        "@webassemblyjs/utf8": "1.13.2"
+        "@vue/reactivity": "3.5.43",
+        "@vue/runtime-core": "3.5.43",
+        "@vue/shared": "3.5.43",
+        "csstype": "^3.2.3"
       }
     },
-    "node_modules/@webassemblyjs/wast-printer": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
-      "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
-      "dev": true,
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
+      "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "@webassemblyjs/ast": "1.14.1",
-        "@xtuc/long": "4.2.2"
+        "@vue/compiler-ssr": "3.5.43",
+        "@vue/runtime-dom": "3.5.43",
+        "@vue/shared": "3.5.43"
       }
     },
+    "node_modules/@vue/shared": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/@vue/shared/-/shared-3.5.43.tgz",
+      "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
+      "license": "MIT"
+    },
     "node_modules/@xmldom/xmldom": {
       "version": "0.8.14",
       "resolved": "http://mirrors.tencent.com/npm/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
         "node": ">=10.0.0"
       }
     },
-    "node_modules/@xtuc/ieee754": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
-      "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "peer": true
-    },
-    "node_modules/@xtuc/long": {
-      "version": "4.2.2",
-      "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
-      "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "peer": true
-    },
     "node_modules/acorn": {
       "version": "8.16.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
         "node": ">=0.4.0"
       }
     },
-    "node_modules/acorn-import-phases": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
-      "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=10.13.0"
-      },
-      "peerDependencies": {
-        "acorn": "^8.14.0"
-      }
-    },
     "node_modules/acorn-jsx": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
         "url": "https://github.com/sponsors/epoberezkin"
       }
     },
-    "node_modules/ajv-formats": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
-      "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "ajv": "^8.0.0"
+        "color-convert": "^2.0.1"
       },
-      "peerDependencies": {
-        "ajv": "^8.0.0"
+      "engines": {
+        "node": ">=8"
       },
-      "peerDependenciesMeta": {
-        "ajv": {
-          "optional": true
-        }
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
-    "node_modules/ajv-formats/node_modules/ajv": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
-      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
-      "dev": true,
+    "node_modules/ant-design-vue": {
+      "version": "4.2.6",
+      "resolved": "http://mirrors.tencent.com/npm/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
+      "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "fast-deep-equal": "^3.1.3",
-        "fast-uri": "^3.0.1",
-        "json-schema-traverse": "^1.0.0",
-        "require-from-string": "^2.0.2"
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-vue": "^7.0.0",
+        "@babel/runtime": "^7.10.5",
+        "@ctrl/tinycolor": "^3.5.0",
+        "@emotion/hash": "^0.9.0",
+        "@emotion/unitless": "^0.8.0",
+        "@simonwep/pickr": "~1.8.0",
+        "array-tree-filter": "^2.1.0",
+        "async-validator": "^4.0.0",
+        "csstype": "^3.1.1",
+        "dayjs": "^1.10.5",
+        "dom-align": "^1.12.1",
+        "dom-scroll-into-view": "^2.0.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.15",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.25",
+        "shallow-equal": "^1.0.0",
+        "stylis": "^4.1.3",
+        "throttle-debounce": "^5.0.0",
+        "vue-types": "^3.0.0",
+        "warning": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12.22.0"
       },
       "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/epoberezkin"
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design-vue"
+      },
+      "peerDependencies": {
+        "vue": ">=3.2.0"
       }
     },
-    "node_modules/ajv-formats/node_modules/json-schema-traverse": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
-      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-      "dev": true,
+    "node_modules/ant-design-vue/node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "http://mirrors.tencent.com/npm/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
       "license": "MIT",
-      "peer": true
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
     },
-    "node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
+    "node_modules/ant-design-vue/node_modules/@emotion/hash": {
+      "version": "0.9.2",
+      "resolved": "http://mirrors.tencent.com/npm/@emotion/hash/-/hash-0.9.2.tgz",
+      "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+      "license": "MIT"
+    },
+    "node_modules/ant-design-vue/node_modules/@emotion/unitless": {
+      "version": "0.8.1",
+      "resolved": "http://mirrors.tencent.com/npm/@emotion/unitless/-/unitless-0.8.1.tgz",
+      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
+      "license": "MIT"
+    },
+    "node_modules/ant-design-vue/node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "http://mirrors.tencent.com/npm/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "license": "MIT"
+    },
+    "node_modules/ant-design-vue/node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "http://mirrors.tencent.com/npm/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
       "license": "MIT",
       "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+        "compute-scroll-into-view": "^1.0.20"
       }
     },
     "node_modules/antd": {
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/array-tree-filter": {
+      "version": "2.1.0",
+      "resolved": "http://mirrors.tencent.com/npm/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
+      "license": "MIT"
+    },
     "node_modules/array.prototype.findlast": {
       "version": "1.2.5",
       "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
         "node": ">= 0.4"
       }
     },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "http://mirrors.tencent.com/npm/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
     "node_modules/available-typed-arrays": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
         "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
-    "node_modules/chrome-trace-event": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
-      "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=6.0"
-      }
-    },
     "node_modules/client-only": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/commander": {
-      "version": "2.20.3",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
     "node_modules/compute-scroll-into-view": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
         "url": "https://github.com/sponsors/mesqueeb"
       }
     },
+    "node_modules/core-js": {
+      "version": "3.50.0",
+      "resolved": "http://mirrors.tencent.com/npm/core-js/-/core-js-3.50.0.tgz",
+      "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
     "node_modules/core-util-is": {
       "version": "1.0.3",
       "resolved": "http://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.3.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/dom-align": {
+      "version": "1.12.4",
+      "resolved": "http://mirrors.tencent.com/npm/dom-align/-/dom-align-1.12.4.tgz",
+      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
+      "license": "MIT"
+    },
+    "node_modules/dom-scroll-into-view": {
+      "version": "2.0.1",
+      "resolved": "http://mirrors.tencent.com/npm/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
+      "license": "MIT"
+    },
     "node_modules/dot-case": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
         "node": ">=0.10.0"
       }
     },
-    "node_modules/events": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
-      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=0.8.x"
-      }
-    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/fast-uri": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
-      "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "BSD-3-Clause",
-      "peer": true
-    },
     "node_modules/fastq": {
       "version": "1.20.1",
       "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
         "node": ">=10.13.0"
       }
     },
-    "node_modules/glob-to-regexp": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
-      "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "peer": true
-    },
     "node_modules/globals": {
       "version": "14.0.0",
       "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-plain-object": {
+      "version": "3.0.1",
+      "resolved": "http://mirrors.tencent.com/npm/is-plain-object/-/is-plain-object-3.0.1.tgz",
+      "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/is-regex": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
         "node": ">= 0.4"
       }
     },
-    "node_modules/jest-worker": {
-      "version": "27.5.1",
-      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
-      "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/node": "*",
-        "merge-stream": "^2.0.0",
-        "supports-color": "^8.0.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      }
-    },
-    "node_modules/jest-worker/node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
     "node_modules/jiti": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/js-yaml": {
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/loader-runner": {
-      "version": "4.3.2",
-      "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
-      "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=6.11.5"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/webpack"
-      }
-    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "http://mirrors.tencent.com/npm/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "http://mirrors.tencent.com/npm/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
       "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "js-tokens": "^3.0.0 || ^4.0.0"
       "version": "0.30.21",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
       "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/sourcemap-codec": "^1.5.5"
         "node": ">= 0.4"
       }
     },
-    "node_modules/merge-stream": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
-      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
     "node_modules/merge2": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
         "node": ">=4"
       }
     },
-    "node_modules/mime-db": {
-      "version": "1.54.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
-      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
     "node_modules/miniflare": {
       "version": "4.20260515.0",
       "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260515.0.tgz",
       "license": "MIT"
     },
     "node_modules/nanoid": {
-      "version": "3.3.12",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
-      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "version": "3.3.19",
+      "resolved": "http://mirrors.tencent.com/npm/nanoid/-/nanoid-3.3.19.tgz",
+      "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
       "funding": [
         {
           "type": "github",
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/nanopop": {
+      "version": "2.4.2",
+      "resolved": "http://mirrors.tencent.com/npm/nanopop/-/nanopop-2.4.2.tgz",
+      "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
+      "license": "MIT"
+    },
     "node_modules/napi-postinstall": {
       "version": "0.3.4",
       "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
       }
     },
     "node_modules/postcss": {
-      "version": "8.5.14",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
-      "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
-      "dev": true,
+      "version": "8.5.28",
+      "resolved": "http://mirrors.tencent.com/npm/postcss/-/postcss-8.5.28.tgz",
+      "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
       "funding": [
         {
           "type": "opencollective",
       ],
       "license": "MIT",
       "dependencies": {
-        "nanoid": "^3.3.11",
+        "nanoid": "^3.3.18",
         "picocolors": "^1.1.1",
         "source-map-js": "^1.2.1"
       },
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/require-from-string": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
-      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=0.10.0"
-      }
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "http://mirrors.tencent.com/npm/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
     },
     "node_modules/resolve": {
       "version": "2.0.0-next.7",
       "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
       "license": "MIT"
     },
-    "node_modules/schema-utils": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
-      "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/json-schema": "^7.0.9",
-        "ajv": "^8.9.0",
-        "ajv-formats": "^2.1.1",
-        "ajv-keywords": "^5.1.0"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/webpack"
-      }
-    },
-    "node_modules/schema-utils/node_modules/ajv": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
-      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "fast-deep-equal": "^3.1.3",
-        "fast-uri": "^3.0.1",
-        "json-schema-traverse": "^1.0.0",
-        "require-from-string": "^2.0.2"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/epoberezkin"
-      }
-    },
-    "node_modules/schema-utils/node_modules/ajv-keywords": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
-      "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "fast-deep-equal": "^3.1.3"
-      },
-      "peerDependencies": {
-        "ajv": "^8.8.2"
-      }
-    },
-    "node_modules/schema-utils/node_modules/json-schema-traverse": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
-      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true
-    },
     "node_modules/scroll-into-view-if-needed": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
       "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
       "license": "MIT"
     },
+    "node_modules/shallow-equal": {
+      "version": "1.2.1",
+      "resolved": "http://mirrors.tencent.com/npm/shallow-equal/-/shallow-equal-1.2.1.tgz",
+      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
+      "license": "MIT"
+    },
     "node_modules/sharp": {
       "version": "0.34.5",
       "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
         "url": "https://opencollective.com/webpack"
       }
     },
-    "node_modules/terser": {
-      "version": "5.47.1",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz",
-      "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "peer": true,
-      "dependencies": {
-        "@jridgewell/source-map": "^0.3.3",
-        "acorn": "^8.15.0",
-        "commander": "^2.20.0",
-        "source-map-support": "~0.5.20"
-      },
-      "bin": {
-        "terser": "bin/terser"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/terser-webpack-plugin": {
-      "version": "5.6.0",
-      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz",
-      "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.25",
-        "jest-worker": "^27.4.5",
-        "schema-utils": "^4.3.0",
-        "terser": "^5.31.1"
-      },
-      "engines": {
-        "node": ">= 10.13.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/webpack"
-      },
-      "peerDependencies": {
-        "webpack": "^5.1.0"
-      },
-      "peerDependenciesMeta": {
-        "@minify-html/node": {
-          "optional": true
-        },
-        "@swc/core": {
-          "optional": true
-        },
-        "@swc/css": {
-          "optional": true
-        },
-        "@swc/html": {
-          "optional": true
-        },
-        "clean-css": {
-          "optional": true
-        },
-        "cssnano": {
-          "optional": true
-        },
-        "csso": {
-          "optional": true
-        },
-        "esbuild": {
-          "optional": true
-        },
-        "html-minifier-terser": {
-          "optional": true
-        },
-        "lightningcss": {
-          "optional": true
-        },
-        "postcss": {
-          "optional": true
-        },
-        "uglify-js": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/throttle-debounce": {
       "version": "5.0.2",
       "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
         }
       }
     },
-    "node_modules/watchpack": {
-      "version": "2.5.1",
-      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
-      "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
-      "dev": true,
+    "node_modules/vue": {
+      "version": "3.5.43",
+      "resolved": "http://mirrors.tencent.com/npm/vue/-/vue-3.5.43.tgz",
+      "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.43",
+        "@vue/compiler-sfc": "3.5.43",
+        "@vue/runtime-dom": "3.5.43",
+        "@vue/server-renderer": "3.5.43",
+        "@vue/shared": "3.5.43"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-types": {
+      "version": "3.0.2",
+      "resolved": "http://mirrors.tencent.com/npm/vue-types/-/vue-types-3.0.2.tgz",
+      "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
-        "glob-to-regexp": "^0.4.1",
-        "graceful-fs": "^4.1.2"
+        "is-plain-object": "3.0.1"
       },
       "engines": {
-        "node": ">=10.13.0"
+        "node": ">=10.15.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "http://mirrors.tencent.com/npm/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
       }
     },
     "node_modules/web-vitals": {
       "dev": true,
       "license": "Apache-2.0"
     },
-    "node_modules/webpack": {
-      "version": "5.106.2",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
-      "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@types/eslint-scope": "^3.7.7",
-        "@types/estree": "^1.0.8",
-        "@types/json-schema": "^7.0.15",
-        "@webassemblyjs/ast": "^1.14.1",
-        "@webassemblyjs/wasm-edit": "^1.14.1",
-        "@webassemblyjs/wasm-parser": "^1.14.1",
-        "acorn": "^8.16.0",
-        "acorn-import-phases": "^1.0.3",
-        "browserslist": "^4.28.1",
-        "chrome-trace-event": "^1.0.2",
-        "enhanced-resolve": "^5.20.0",
-        "es-module-lexer": "^2.0.0",
-        "eslint-scope": "5.1.1",
-        "events": "^3.2.0",
-        "glob-to-regexp": "^0.4.1",
-        "graceful-fs": "^4.2.11",
-        "loader-runner": "^4.3.1",
-        "mime-db": "^1.54.0",
-        "neo-async": "^2.6.2",
-        "schema-utils": "^4.3.3",
-        "tapable": "^2.3.0",
-        "terser-webpack-plugin": "^5.3.17",
-        "watchpack": "^2.5.1",
-        "webpack-sources": "^3.3.4"
-      },
-      "bin": {
-        "webpack": "bin/webpack.js"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/webpack"
-      },
-      "peerDependenciesMeta": {
-        "webpack-cli": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/webpack-sources": {
       "version": "3.4.1",
       "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz",
         "node": ">=10.13.0"
       }
     },
-    "node_modules/webpack/node_modules/eslint-scope": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
-      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "peer": true,
-      "dependencies": {
-        "esrecurse": "^4.3.0",
-        "estraverse": "^4.1.1"
-      },
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/webpack/node_modules/estraverse": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
-      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "peer": true,
-      "engines": {
-        "node": ">=4.0"
-      }
-    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
index dedd5f21c80a1b37671aa9424e4a365036cb610a..7e54e08b9c8f763238313aa08dce0f9766d1857c 100644 (file)
@@ -19,6 +19,8 @@
   },
   "dependencies": {
     "@ant-design/icons": "^6.0.0",
+    "@vitejs/plugin-vue": "^6.0.9",
+    "ant-design-vue": "^4.2.6",
     "antd": "^6.6.4",
     "drizzle-orm": "0.45.2",
     "mammoth": "^1.12.1",
@@ -27,6 +29,7 @@
     "react": "19.2.6",
     "react-dom": "19.2.6",
     "sharp": "0.34.5",
+    "vue": "^3.5.43",
     "xlsx": "^0.18.5"
   },
   "devDependencies": {
diff --git a/src-vue/App.vue b/src-vue/App.vue
new file mode 100644 (file)
index 0000000..a2fcaab
--- /dev/null
@@ -0,0 +1,41 @@
+<script setup>
+import { onMounted } from "vue";
+import { ConfigProvider, theme as antdTheme } from "ant-design-vue";
+import zhCN from "ant-design-vue/es/locale/zh_CN";
+import brandTheme from "./styles/theme.json";
+import Home from "./Home.vue";
+
+/** Serialize official antd tokens without turning unitless values into lengths. */
+function tokenCssValue(name, value) {
+  if (typeof value !== "number") return value;
+  if (/^(lineHeight|fontWeight|opacity|zIndex|sizeStep|sizeUnit)/i.test(name)) return String(value);
+  const length = /^(fontSize|borderRadius|controlHeight|size|padding|margin)|(?:Size|Height|Width|Radius|Padding|Margin)/;
+  return length.test(name) ? `${value}px` : String(value);
+}
+
+const companyTheme = { ...brandTheme, cssVar: { key: "stationbuilder" } };
+const kebab = (name) =>
+  name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
+
+onMounted(() => {
+  const resolved = antdTheme.getDesignToken(companyTheme);
+  const declarations = Object.entries(resolved)
+    .filter(([, value]) => typeof value === "string" || typeof value === "number")
+    .map(([name, value]) => "--ant-" + kebab(name) + ":" + tokenCssValue(name, value));
+  for (const [component, tokens] of Object.entries(brandTheme.components)) {
+    for (const [name, value] of Object.entries(tokens)) {
+      declarations.push("--ant-" + kebab(component) + "-" + kebab(name) + ":" + tokenCssValue(name, value));
+    }
+  }
+  const style = document.createElement("style");
+  style.setAttribute("data-station-brand-tokens", "");
+  style.textContent = ":root{" + declarations.join(";") + "}";
+  document.head.appendChild(style);
+});
+</script>
+
+<template>
+  <ConfigProvider :theme="companyTheme" :locale="zhCN" :button="{ autoInsertSpace: false }">
+    <Home />
+  </ConfigProvider>
+</template>
diff --git a/src-vue/Home.vue b/src-vue/Home.vue
new file mode 100644 (file)
index 0000000..e400a6a
--- /dev/null
@@ -0,0 +1,685 @@
+<script setup>
+import { ref, computed, watch, onMounted, nextTick, h } from "vue";
+import { message, Drawer, Dropdown, Tag } from "ant-design-vue";
+import {
+  AimOutlined, ApartmentOutlined, BarsOutlined, BlockOutlined, CaretDownOutlined,
+  CaretRightOutlined, ControlOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined,
+  EditOutlined, EllipsisOutlined, EyeOutlined, LinkOutlined, PlusOutlined,
+  PlusSquareOutlined, RobotOutlined, SearchOutlined, SnippetsOutlined, UploadOutlined,
+  ZoomInOutlined, ZoomOutOutlined,
+} from "@ant-design/icons-vue";
+
+import UiButton from "./ui/UiButton.vue";
+import UiInput from "./ui/UiInput.vue";
+import UiSelect from "./ui/UiSelect.vue";
+import UiTable from "./ui/UiTable.vue";
+import UiTabbedRegion from "./ui/UiTabbedRegion.vue";
+import UiActions from "./ui/UiActions.vue";
+import UiOverlay from "./ui/UiOverlay.vue";
+import SvgIcon from "./components/SvgIcon.vue";
+import SideMenu from "./components/SideMenu.vue";
+import SearchInput from "./components/SearchInput.vue";
+import Pagination from "./components/Pagination.vue";
+import StatusTag from "./components/StatusTag.vue";
+import Field from "./components/Field.vue";
+import Panel from "./components/Panel.vue";
+import PrimaryButton from "./components/PrimaryButton.vue";
+import Steps from "./components/Steps.vue";
+import SiteEditTabs from "./components/SiteEditTabs.vue";
+import Login from "./components/Login.vue";
+import ModalComp from "./components/Modal.vue";
+import Dashboard from "./pages/Dashboard.vue";
+import ImagePage from "./components/ImagePage.vue";
+import ResourcePlaceholder from "./components/ResourcePlaceholder.vue";
+import DomainPageThumb from "./components/DomainPageThumb.vue";
+import DashboardThumb from "./components/DashboardThumb.vue";
+import Projects from "./pages/Projects.vue";
+import Basic from "./pages/Basic.vue";
+import CompanyDomainLibrary from "./pages/domain/CompanyDomainLibrary.vue";
+import DomainDetail from "./pages/domain/DomainDetail.vue";
+import StyleLibrary from "./pages/StyleLibrary.vue";
+import DeviceTemplates from "./pages/DeviceTemplates.vue";
+import ToolSettings from "./pages/ToolSettings.vue";
+import DomainSettings from "./pages/DomainSettings.vue";
+import SiteConfigurationContext from "./pages/SiteConfigurationContext.vue";
+import PageConfig from "./pages/PageConfig.vue";
+import Functions from "./pages/Functions.vue";
+import Assets from "./pages/Assets.vue";
+import ManualAssetConfiguration from "./pages/ManualAssetConfiguration.vue";
+import Generate from "./pages/Generate.vue";
+import Result from "./pages/Result.vue";
+import Preview from "./pages/Preview.vue";
+import Placeholder from "./pages/Placeholder.vue";
+
+import { fetchDb, buildMuLeaves } from "./lib/api.js";
+import { meta, SITE_EDIT_STEPS, MODELS } from "./lib/meta.js";
+
+const notify = (m) => { message.info(m, 2.2); };
+const confirm = (msg) => Promise.resolve(window.confirm(msg));
+
+// ---- state ----
+const page = ref("projects");
+const projectNavOpen = ref(true);
+const extensionNavOpen = ref(true);
+const resourceNavOpen = ref(true);
+const navCollapsed = ref(false);
+const systemNavOpen = ref(true);
+
+const modal = ref("");
+const promptData = ref(null);
+const model = ref("Hy3");
+const modelOpen = ref(false);
+const aiOpen = ref(false);
+const chatSessionId = (() => {
+  try {
+    const k = "sb-ai-sid";
+    let v = sessionStorage.getItem(k);
+    if (!v) { v = "web-" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); sessionStorage.setItem(k, v); }
+    return v;
+  } catch { return "web-" + Math.random().toString(36).slice(2, 10); }
+})();
+const fabPos = ref((() => { try { const s = localStorage.getItem("ai-fab-pos"); return s ? JSON.parse(s) : null; } catch { return null; } })());
+const fabRef = ref(null);
+const routeAppliedRef = ref(false);
+const dragState = ref(null);
+const lastPosRef = ref(null);
+
+const aiMessages = ref((() => { try { const s = sessionStorage.getItem("sb-ai-msgs"); return s ? JSON.parse(s) : []; } catch { return []; } })());
+watch(aiMessages, (val) => { try { sessionStorage.setItem("sb-ai-msgs", JSON.stringify(val.filter((m) => m.content && m.content.trim()))); } catch {} }, { deep: true });
+const aiDraft = ref("");
+const aiBusy = ref(false);
+const aiEndRef = ref(null);
+
+const sites = ref([]);
+const siteId = ref(null);
+const site = ref(null);
+const loading = ref(true);
+const saving = ref(false);
+const dirty = ref(false);
+const domains = ref([]);
+const muTree = ref([]);
+const pageStyles = ref([]);
+const openDomainTemplateId = ref(null);
+const domainFilter = ref(null);
+const deviceTemplateDomain = ref((() => { try { return new URLSearchParams(window.location.search).get("domain") || ""; } catch { return ""; } })());
+const domainOptions = ref([]);
+const addingDomain = ref(false);
+const newDomainName = ref("");
+
+const view = ref("table");
+const siteEditSection = ref("basic");
+const assetSaveRef = ref(null);
+const pageSaveRef = ref(null);
+const menuSaveRef = ref(null);
+const configurationSaving = ref(false);
+const assetConfigMode = ref("quick");
+const pageConfigTab = ref("ops");
+const functionTab = ref("mu");
+const previewTab = ref("info");
+const feedbackCube = ref(null);
+const refining = ref(false);
+const refineLog = ref("");
+
+// ---- prompt viewers ----
+const viewPrompt = async (cubeId, name, content) => {
+  const sid = site.value?.id ?? siteId.value;
+  if (sid == null) { notify("请先创建或打开一个站点"); return; }
+  try {
+    const data = cubeId != null
+      ? await fetchDb(`/sites/${sid}/cubes/${cubeId}/prompt`)
+      : await fetchDb(`/sites/${sid}/cubes/prompt?${new URLSearchParams({ name: name || "", content: content || "" }).toString()}`);
+    promptData.value = { name, text: data.prompt };
+    modal.value = "prompt";
+  } catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewSitePrompt = async () => {
+  const sid = site.value?.id ?? siteId.value;
+  if (sid == null) { notify("请先创建或打开一个站点"); return; }
+  try { const data = await fetchDb(`/sites/${sid}/prompt`); promptData.value = { name: "站点级提示词(通用上下文)", text: data.prompt }; modal.value = "prompt"; }
+  catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewCompletePrompt = async () => {
+  if (!site.value?.id) { notify("请先创建或打开一个站点"); return; }
+  try {
+    const qs = new URLSearchParams({ name: site.value.name || "", domain: site.value.domain || "", description: site.value.description || "" }).toString();
+    const data = await fetchDb(`/sites/${site.value.id}/complete-prompt?${qs}`);
+    promptData.value = { name: "AI 智能补全", text: data.prompt }; modal.value = "completePrompt";
+  } catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewCompleteAssetsPrompt = async () => {
+  if (!site.value?.id) { notify("请先创建或打开一个站点"); return; }
+  try {
+    const qs = new URLSearchParams({ name: site.value.name || "", domain: site.value.domain || "", description: site.value.description || "", supplement: site.value.supplement || "" }).toString();
+    const data = await fetchDb(`/sites/${site.value.id}/complete-assets-prompt?${qs}`);
+    promptData.value = { name: "AI 资产补全", text: data.prompt }; modal.value = "completePrompt";
+  } catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewCompleteCubesPrompt = async () => {
+  if (!site.value?.id) { notify("请先创建或打开一个站点"); return; }
+  try {
+    const qs = new URLSearchParams({ name: site.value.name || "", domain: site.value.domain || "", description: site.value.description || "", supplement: site.value.supplement || "" }).toString();
+    const data = await fetchDb(`/sites/${site.value.id}/complete-cubes-prompt?${qs}`);
+    promptData.value = { name: "AI Cube 页面补全", text: data.prompt }; modal.value = "completePrompt";
+  } catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewDtCompletePrompt = async (id, name, domain, description) => {
+  try { const qs = new URLSearchParams({ name, domain, description }).toString(); const data = await fetchDb(`/domain-templates/${id}/complete-prompt?${qs}`); promptData.value = { name: "AI 智能补全", text: data.prompt }; modal.value = "completePrompt"; }
+  catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewDtCompleteAssetsPrompt = async (id, name, domain, description, supplement) => {
+  try { const qs = new URLSearchParams({ name, domain, description, supplement }).toString(); const data = await fetchDb(`/domain-templates/${id}/complete-assets-prompt?${qs}`); promptData.value = { name: "AI 资产补全", text: data.prompt }; modal.value = "completePrompt"; }
+  catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewDtCompleteCubesPrompt = async (id, name, domain, description, supplement) => {
+  try { const qs = new URLSearchParams({ name, domain, description, supplement }).toString(); const data = await fetchDb(`/domain-templates/${id}/complete-cubes-prompt?${qs}`); promptData.value = { name: "AI Cube 页面补全", text: data.prompt }; modal.value = "completePrompt"; }
+  catch (e) { notify("读取提示词失败: " + e.message); }
+};
+const viewDeviceCpCompletePrompt = async (id) => {
+  try { const data = await fetchDb(`/device-templates/${id}/complete-collection-points-prompt`); promptData.value = { name: "AI 采集点补全", text: data.prompt }; modal.value = "completePrompt"; }
+  catch (e) { notify("读取提示词失败: " + e.message); }
+};
+
+const openFeedback = (cube) => {
+  if (!cube.id) { notify("请先生成该页面再提出修改意见"); return; }
+  feedbackCube.value = cube; refineLog.value = ""; modal.value = "feedback";
+};
+const refineCube = async (cube, feedback) => {
+  if (!site.value?.id || !cube.id) return;
+  if (!feedback.trim()) { notify("请填写修改意见"); return; }
+  refining.value = true; refineLog.value = "";
+  const watchdog = setTimeout(() => {
+    refining.value = false; modal.value = ""; feedbackCube.value = null;
+    notify("重新生成超过预期时长(>10 分钟),已自动结束并关闭窗口,可重新打开再试");
+  }, 600000);
+  try {
+    const res = await fetch(`/api/db/sites/${site.value.id}/cubes/${cube.id}/refine`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feedback }) });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = "";
+    for (;;) {
+      const { done, value } = await reader.read(); if (done) break;
+      buf += dec.decode(value, { stream: true });
+      const lines = buf.split("\n"); buf = lines.pop() || "";
+      for (const line of lines) {
+        if (!line.trim()) continue;
+        try { const ev = JSON.parse(line); if (ev.log) refineLog.value += ev.log; } catch {}
+      }
+    }
+    notify(`已根据修改意见重新生成「${cube.name}」`);
+    refining.value = false; modal.value = ""; feedbackCube.value = null;
+    try { const refreshed = await fetchDb(`/sites/${site.value.id}`); site.value = refreshed; siteId.value = site.value.id; } catch {}
+  } catch (e) { notify("重新生成失败:" + (e?.message || e)); }
+  finally { clearTimeout(watchdog); refining.value = false; }
+};
+
+watch([aiMessages, aiOpen], async () => { await nextTick(); if (aiEndRef.value) aiEndRef.value.scrollIntoView({ block: "end" }); });
+
+const loadPageStyles = async () => {
+  try { const d = await fetchDb("/page-styles"); pageStyles.value = d || []; } catch (e) { notify("加载页面样式失败: " + e.message); }
+};
+
+onMounted(() => {
+  setLoading(true);
+  Promise.all([
+    fetchDb("/sites"),
+    fetchDb("/domains"),
+    fetchDb("/mu-tree"),
+    fetchDb("/page-styles"),
+  ]).then(([s, d, m, ps]) => {
+    sites.value = s || [];
+    domains.value = (d || []).map((x) => x.name);
+    domainOptions.value = (d || []).map((x) => ({ id: x.id, name: x.name }));
+    muTree.value = m || [];
+    pageStyles.value = ps || [];
+  }).catch((e) => notify("加载失败: " + e.message)).finally(() => setLoading(false));
+});
+
+const reloadDomains = async () => {
+  try {
+    const d = await fetchDb("/domains"); const arr = d || [];
+    domainOptions.value = arr.map((x) => ({ id: x.id, name: x.name }));
+    domains.value = arr.map((x) => x.name);
+  } catch (e) { notify("加载领域失败: " + e.message); }
+};
+const handleDomainDeleted = async (id) => { await reloadDomains(); if (domainFilter.value === id) domainFilter.value = null; };
+
+const loadSite = async (id) => { const data = await fetchDb(`/sites/${id}`); site.value = data; siteId.value = id; };
+const createSite = async () => {
+  const data = await fetchDb("/sites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "", domain: "", description: "", supplement: "" }) });
+  sites.value = [data, ...sites.value]; site.value = data; siteId.value = data.id; return data.id;
+};
+const createSubSite = async (parentId) => {
+  const parent = sites.value.find((s) => s.id === parentId);
+  const data = await fetchDb("/sites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "", domain: parent?.domain || "", description: "", supplement: "", parent_id: parentId }) });
+  sites.value = [data, ...sites.value]; site.value = data; siteId.value = data.id;
+  page.value = "basic"; window.history.replaceState({}, "", "?page=basic&id=" + data.id);
+};
+const deleteSites = async (ids) => {
+  if (!ids.length) return;
+  if (!(await confirm(`确认删除选中的 ${ids.length} 个站点?该操作不可恢复,相关资产、页面与生成文件将一并删除。`))) return;
+  try {
+    await fetchDb("/sites", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }) });
+    sites.value = sites.value.filter((s) => !ids.includes(s.id));
+    if (siteId.value != null && ids.includes(siteId.value)) { site.value = null; siteId.value = null; }
+    notify(`已删除 ${ids.length} 个站点`);
+  } catch (e) { notify("删除失败: " + e.message); }
+};
+const confirmUnsaved = async () => {
+  if (!dirty.value) return true;
+  const ok = window.confirm("当前站点有未保存的修改,切换站点将丢失这些修改。是否继续?");
+  if (ok) { dirty.value = false; return true; }
+  return false;
+};
+const go = async (target, sid) => {
+  try {
+    if (page.value === "siteEdit" && (target !== page.value || (typeof sid === "number" && sid !== siteId.value))) {
+      if (siteEditSection.value === "assets" && assetSaveRef.value) await assetSaveRef.value();
+      if (siteEditSection.value === "functions" && menuSaveRef.value) await menuSaveRef.value();
+    }
+    if (target === "siteEdit") siteEditSection.value = "basic";
+    if (sid === "new") { await createSite(); page.value = target; window.history.replaceState({}, "", "?page=" + target); return; }
+    let curId = sid ?? siteId.value;
+    if (target === "siteEdit" && !curId) {
+      const first = sites.value[0];
+      if (!first) { page.value = "projects"; window.history.replaceState({}, "", "?page=projects"); notify("暂无可编辑站点,请先新建站点"); return; }
+      curId = first.id;
+    }
+    const needsSite = ["siteEdit", "basic", "assets", "opsflow", "pageConfig", "functions", "generate", "result", "preview"].includes(target);
+    if (needsSite && !curId) { page.value = "projects"; window.history.replaceState({}, "", "?page=projects"); notify("请先选择或新建站点"); return; }
+    if (curId && (!site.value || site.value.id !== curId)) {
+      if (!(await confirmUnsaved())) return;
+      await loadSite(curId);
+    }
+    page.value = target;
+    window.history.replaceState({}, "", "?page=" + target + (curId ? "&id=" + curId : ""));
+  } catch (e) { notify("操作失败: " + (e?.message || e)); }
+};
+const createSiteFromDomainTemplate = async (templateId) => {
+  if (!(await confirmUnsaved())) return;
+  const created = await fetchDb(`/domain-templates/${templateId}/create-site`, { method: "POST", headers: { "Content-Type": "application/json" } });
+  if (!created?.id) throw new Error("未返回有效的站点编号");
+  sites.value = [created, ...sites.value]; site.value = created; siteId.value = created.id; dirty.value = false;
+  siteEditSection.value = "basic"; page.value = "siteEdit";
+  window.history.replaceState({}, "", `?page=siteEdit&id=${created.id}`);
+  notify("已根据领域模板创建草稿站点,可继续调整配置");
+  fetchDb("/sites").then((data) => (sites.value = data)).catch(() => {});
+};
+const saveHierarchySite = async (values, id) => {
+  const saved = await fetchDb(id ? `/sites/${id}` : "/sites", { method: id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values) });
+  sites.value = await fetchDb("/sites"); if (site.value?.id === id) site.value = saved; return saved;
+};
+const saveCurrentConfiguration = async () => {
+  if (saving.value) throw new Error("基本信息正在保存,请稍后重试");
+  const section = page.value === "siteEdit" ? siteEditSection.value : page.value;
+  if (section === "assets" && assetSaveRef.value) await assetSaveRef.value();
+  if (section === "pageConfig" && pageSaveRef.value) await pageSaveRef.value();
+  if (section === "functions" && menuSaveRef.value) await menuSaveRef.value();
+  if (dirty.value && site.value) await fetchDb(`/sites/${site.value.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(site.value) });
+  dirty.value = false; sites.value = await fetchDb("/sites");
+};
+const saveConfigurationDraft = async () => { if (configurationSaving.value) return; configurationSaving.value = true; try { await saveCurrentConfiguration(); notify("草稿已保存"); } catch (error) { notify("保存失败:" + error.message); } finally { configurationSaving.value = false; } };
+const changeConfigurationStep = async (section) => {
+  if (configurationSaving.value) return; configurationSaving.value = true;
+  try { await saveCurrentConfiguration(); if (page.value === "siteEdit") siteEditSection.value = section; else await go(section, site.value?.id); }
+  catch (error) { notify("请先完成保存:" + error.message); } finally { configurationSaving.value = false; }
+};
+const backToHierarchy = async () => { if (configurationSaving.value) return; configurationSaving.value = true; try { await saveCurrentConfiguration(); await go("projects"); } catch (error) { notify("请先完成保存:" + error.message); } finally { configurationSaving.value = false; } };
+const nextConfigurationStep = async () => {
+  const section = page.value === "siteEdit" ? siteEditSection.value : page.value;
+  const index = SITE_EDIT_STEPS.indexOf(section);
+  if (index < 4) return changeConfigurationStep(SITE_EDIT_STEPS[index + 1]);
+  if (configurationSaving.value) return; configurationSaving.value = true;
+  try { await saveCurrentConfiguration(); await go("generate", site.value?.id); } catch (error) { notify("请先完成保存:" + error.message); } finally { configurationSaving.value = false; }
+};
+const applyTemplateToSite = async (templateId, replace) => {
+  if (!site.value) return;
+  const applied = await fetchDb(`/sites/${site.value.id}/apply-template`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_id: templateId, replace }) });
+  site.value = applied; dirty.value = false; sites.value = await fetchDb("/sites");
+  if (page.value === "siteEdit") siteEditSection.value = "assets"; else await go("assets", site.value.id);
+  notify("模板已应用,可在当前工作区调整资产");
+};
+const savePageConfiguration = async (config) => { if (!site.value) return; const saved = await fetchDb(`/sites/${site.value.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ page_config: config }) }); site.value = saved; };
+const openDomainDeviceTemplates = (domainName) => { deviceTemplateDomain.value = domainName; page.value = "deviceTemplates"; window.history.replaceState({}, "", `?page=deviceTemplates&domain=${encodeURIComponent(domainName)}`); };
+const openDeviceTemplateEditor = (templateId) => { page.value = "deviceTemplates"; window.history.replaceState({}, "", `?page=deviceTemplates&openTemplate=${Number(templateId)}`); };
+
+watch(loading, () => {
+  if (loading.value || routeAppliedRef.value) return;
+  routeAppliedRef.value = true;
+  const params = new URLSearchParams(window.location.search);
+  const target = params.get("page");
+  if (!target || target === "projects" || !Object.prototype.hasOwnProperty.call(meta, target)) return;
+  const rawId = Number(params.get("id"));
+  go(target, Number.isInteger(rawId) && rawId > 0 ? rawId : undefined);
+});
+
+const saveSiteBasic = async (patch) => {
+  if (!site.value) return;
+  const next = { ...site.value, ...patch };
+  site.value = next; dirty.value = true; saving.value = true;
+  try {
+    await fetchDb(`/sites/${site.value.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) });
+    sites.value = sites.value.map((s) => (s.id === site.value.id ? { ...s, name: next.name, domain: next.domain, status: next.status } : s));
+    dirty.value = false;
+  } catch (e) { notify("保存失败: " + e.message); }
+  finally { saving.value = false; }
+};
+const saveAssets = async (assets) => {
+  if (!site.value) return;
+  site.value = { ...site.value, assets }; saving.value = true;
+  try { await fetchDb(`/sites/${site.value.id}/assets`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ assets }) }); }
+  catch (e) { notify("资产保存失败: " + e.message); } finally { saving.value = false; }
+};
+const saveMu = async (selected) => {
+  if (!site.value) return;
+  try { const saved = await fetchDb(`/sites/${site.value.id}/mu`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selected }) }); site.value = saved; }
+  catch (e) { notify("MU 选择保存失败: " + e.message); throw e; }
+};
+const addDomain = async (name) => {
+  const n = (name || "").trim();
+  if (!n) return;
+  if (n.length > 6) { notify("领域名称不超过 6 个字"); return; }
+  try { await fetchDb("/domains", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n }) }); await reloadDomains(); }
+  catch (e) { notify("添加领域失败: " + e.message); }
+};
+const doAddDomain = async () => { if (!newDomainName.value.trim()) return; await addDomain(newDomainName.value); addingDomain.value = false; newDomainName.value = ""; };
+const uploadFile = async (file) => {
+  if (!site.value) return;
+  const reader = new FileReader(); reader.readAsDataURL(file);
+  const data = await new Promise((res) => (reader.onload = () => res(String(reader.result))));
+  const base64 = data.split(",")[1];
+  try {
+    const result = await fetchDb(`/sites/${site.value.id}/files`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, mime: file.type, data: base64 }) });
+    site.value = { ...site.value, files: [...site.value.files, result] };
+    notify("已上传 " + file.name);
+  } catch (e) { notify("上传失败: " + e.message); }
+};
+const sendAi = async () => {
+  const text = aiDraft.value.trim();
+  if (!text || aiBusy.value) return;
+  aiMessages.value = [...aiMessages.value, { role: "user", content: text }, { role: "assistant", content: "" }];
+  aiDraft.value = ""; aiBusy.value = true;
+  try {
+    const siteInfo = site.value ? { name: site.value.name, domain: site.value.domain, description: site.value.description, status: site.value.status, assets: (site.value.assets || []).filter((a) => a.type).map((a) => ({ type: a.type, count: a.count })), cubes: (site.value.cubes || []).map((c) => ({ name: c.name, gen_filename: c.gen_filename || null })) } : null;
+    const OVERVIEW_MAX = 40;
+    const overview = sites.value && sites.value.length
+      ? { total: sites.value.length, truncated: sites.value.length > OVERVIEW_MAX, sites: sites.value.slice(0, OVERVIEW_MAX).map((s) => ({ name: s.name, domain: s.domain, status: s.status, device_count: s.device_count, cube_count: s.cube_count, gen_count: s.gen_count, mu_count: s.mu_count })) }
+      : { total: 0, truncated: false, sites: [] };
+    const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text, model: model.value, sessionId: chatSessionId, history: aiMessages.value.map((m) => ({ role: m.role, content: m.content })).slice(-20), site: siteInfo, overview }) });
+    if (!res.ok || !res.body) { const t = await res.text().catch(() => ""); throw new Error(t || "HTTP " + res.status); }
+    const reader = res.body.getReader(); const dec = new TextDecoder(); let acc = "";
+    for (;;) { const { done, value } = await reader.read(); if (done) break; acc += dec.decode(value, { stream: true }); aiMessages.value = (() => { const c = aiMessages.value.slice(); c[c.length - 1] = { role: "assistant", content: acc }; return c; })(); }
+    if (!acc.trim()) aiMessages.value = (() => { const c = aiMessages.value.slice(); c[c.length - 1] = { role: "assistant", content: "(模型未返回内容)" }; return c; })();
+  } catch (e) {
+    aiMessages.value = (() => { const c = aiMessages.value.slice(); const last = c[c.length - 1]; if (last && last.role === "assistant") { c[c.length - 1] = { role: "assistant", content: last.content + "\n[连接出错] " + (e?.message || e) }; } else c.push({ role: "assistant", content: "[连接出错] " + (e?.message || e) }); return c; })();
+  } finally { aiBusy.value = false; }
+};
+
+// FAB drag
+const onFabPointerDown = (e) => {
+  const btn = fabRef.value; if (!btn) return;
+  const r = btn.getBoundingClientRect();
+  dragState.value = { active: true, startX: e.clientX, startY: e.clientY, origX: r.left, origY: r.top, moved: false };
+  try { btn.setPointerCapture(e.pointerId); } catch {}
+};
+const onFabPointerMove = (e) => {
+  const ds = dragState.value; if (!ds || !ds.active) return;
+  const dx = e.clientX - ds.startX, dy = e.clientY - ds.startY;
+  if (Math.abs(dx) > 4 || Math.abs(dy) > 4) ds.moved = true;
+  if (!ds.moved) return;
+  const btn = fabRef.value; const w = btn.offsetWidth, h = btn.offsetHeight;
+  let x = Math.max(0, Math.min(ds.origX + dx, window.innerWidth - w));
+  let y = Math.max(0, Math.min(ds.origY + dy, window.innerHeight - h));
+  const p = { x, y }; lastPosRef.value = p; fabPos.value = p;
+};
+const onFabPointerUp = (e) => {
+  const ds = dragState.value; dragState.value = null;
+  try { fabRef.value?.releasePointerCapture(e.pointerId); } catch {}
+  if (!ds) return;
+  if (ds.moved) { if (lastPosRef.value) localStorage.setItem("ai-fab-pos", JSON.stringify(lastPosRef.value)); }
+  else { aiOpen.value = true; }
+};
+const resetFab = () => { fabPos.value = null; lastPosRef.value = null; try { localStorage.removeItem("ai-fab-pos"); } catch {} };
+
+// ---- derived ----
+const muLeaves = computed(() => buildMuLeaves(muTree.value));
+const renderDashboard = (cube, large) =>
+  h(DashboardThumb, { siteId: site.value?.id, cube, fixedHeight: large ? Math.min(680, window.innerHeight - 180) : 145 });
+const renderDomainDashboard = (domainPage, large) =>
+  h(DomainPageThumb, { jsonText: domainPage?.json_text, fixedHeight: large ? Math.min(680, window.innerHeight - 180) : 145 });
+
+const siteEditMeta = computed(() => meta[siteEditSection.value]);
+const projectArea = computed(() => ["projects", "siteEdit", "basic", "assets", "pageConfig", "functions", "generate", "result", "preview"].includes(page.value));
+const domainArea = computed(() => ["domains", "domainDetail"].includes(page.value));
+const resourceArea = computed(() => ["deviceTemplates", "monitor-dashboard", "twin-model", "ops-template", "knowledge-template"].includes(page.value));
+const systemArea = computed(() => ["toolSettings", "domainSettings"].includes(page.value));
+const extensionArea = computed(() => ["dashboard", "report-dashboard", "report-doc", "basic-components", "func-components", "filters", "datasource"].includes(page.value));
+const manualAssetActive = computed(() => page.value === "assets" || (page.value === "siteEdit" && siteEditSection.value === "assets"));
+
+const siteHierarchy = computed(() => {
+  if (!siteId.value) return null;
+  const byId = new Map(sites.value.map((s) => [s.id, s]));
+  const current = byId.get(siteId.value);
+  if (!current) return null;
+  const ancestors = []; let p = current;
+  while (p) { ancestors.unshift(p); p = p.parent_id ? byId.get(p.parent_id) : undefined; }
+  const children = sites.value.filter((s) => s.parent_id === siteId.value);
+  return { ancestors, children, current };
+});
+
+const menuItems = computed(() => [
+  { key: "domains", label: "领域方案", icon: h(SvgIcon, { name: "domain-solutions", size: 16 }) },
+  { key: "projects", label: "站点管理", icon: h(SvgIcon, { name: "site-management", size: 16 }) },
+  { key: "resources", label: "建站资源", icon: h(SvgIcon, { name: "building-resources", size: 16 }), children: [
+    { key: "deviceTemplates", label: "设备模板", icon: h(SvgIcon, { name: "device-templates", size: 16 }) },
+    { key: "monitor-dashboard", label: "监控仪表板", icon: h(SvgIcon, { name: "monitoring-dashboards", size: 16 }) },
+    { key: "twin-model", label: "数字孪生模型", icon: h(SvgIcon, { name: "digital-twin-models", size: 16 }) },
+    { key: "ops-template", label: "运维模板", icon: h(SvgIcon, { name: "operations-templates", size: 16 }) },
+    { key: "knowledge-template", label: "知识模板", icon: h(SvgIcon, { name: "knowledge-templates", size: 16 }) },
+  ] },
+  { key: "system", label: "系统设定", icon: h(SvgIcon, { name: "system-settings", size: 16 }), children: [
+    { key: "toolSettings", label: "内部工具配置", icon: h(SvgIcon, { name: "internal-tools", size: 16 }) },
+    { key: "domainSettings", label: "领域配置", icon: h(SvgIcon, { name: "domain-settings", size: 16 }) },
+  ] },
+]);
+
+function onMenuClick({ key }) { if (key === "domains") domainFilter.value = null; go(key); }
+function onMenuOpenChange(keys) { resourceNavOpen.value = keys.includes("resources"); systemNavOpen.value = keys.includes("system"); }
+</script>
+
+<template>
+  <div class="app-shell page sb-brand-shell">
+    <header class="topbar wise-header">
+      <UiButton className="wise-menu-button" type="button" aria-label="展开或收起菜单" :aria-expanded="!navCollapsed" @click="navCollapsed = !navCollapsed"><img src="/t/stationbuilder/wise-header/menu.svg" alt="" /></UiButton>
+      <a class="wise-logo-link" href="#" aria-label="WISE-IoT 首页" @click.prevent><img src="/t/stationbuilder/wise-header/wise-iot-logo.svg" alt="WISE-IoT" /></a>
+      <UiButton className="wise-product-switch" type="button" aria-label="切换产品">
+        <img src="/t/stationbuilder/wise-header/folder.svg" alt="" />
+        <span>StationBuilder</span><strong>| 建站工具 v0.1</strong>
+        <img src="/t/stationbuilder/wise-header/caret-down.svg" alt="" />
+      </UiButton>
+      <nav class="wise-header-actions" aria-label="顶部工具栏">
+        <UiButton type="button" aria-label="搜索" title="搜索"><img src="/t/stationbuilder/wise-header/search.svg" alt="" /></UiButton>
+        <UiButton type="button" aria-label="通知" title="通知"><img src="/t/stationbuilder/wise-header/bell.svg" alt="" /></UiButton>
+        <UiButton type="button" className="wise-header-secondary" aria-label="主题设置" title="主题设置"><img src="/t/stationbuilder/wise-header/theme.svg" alt="" /></UiButton>
+        <UiButton type="button" className="wise-header-secondary" aria-label="收藏" title="收藏"><img src="/t/stationbuilder/wise-header/favorite.svg" alt="" /></UiButton>
+        <UiButton type="button" aria-label="系统设置" title="系统设置"><img src="/t/stationbuilder/wise-header/settings.svg" alt="" /></UiButton>
+        <UiButton type="button" className="wise-avatar" aria-label="管理员账户" title="管理员账户" @click="go('login')">管</UiButton>
+        <span class="wise-powered-divider" aria-hidden="true" />
+        <img class="wise-powered-logo" src="/t/stationbuilder/wise-header/powered-by-wise-iot.svg" alt="Powered by WISE-IoT" />
+      </nav>
+    </header>
+    <div class="body">
+      <aside class="left-menu sb-brand-nav">
+        <nav aria-label="StationBuilder 主菜单">
+          <SideMenu
+            :collapsed="navCollapsed"
+            :selected-keys="[domainArea ? 'domains' : projectArea ? 'projects' : page]"
+            :open-keys="navCollapsed ? undefined : [...(resourceNavOpen ? ['resources'] : []), ...(systemNavOpen ? ['system'] : [])]"
+            :on-open-change="onMenuOpenChange"
+            :on-click="onMenuClick"
+            :items="menuItems"
+          />
+        </nav>
+        <a v-if="!navCollapsed" class="design-doc-link" href="https://aidemo.istation.cloud/t/stationbuilder/design-doc.html" target="_blank" rel="noopener">RDP文档</a>
+      </aside>
+      <aside class="side-nav">
+        <div class="module-switch">
+          <UiButton :class="['projects','basic','assets','pageConfig','functions','generate','result','preview'].includes(page) ? 'active' : ''" @click="go('projects')"><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></UiButton>
+          <UiButton :class="['domains','domainDetail','deviceTemplates'].includes(page) ? 'active' : ''" @click="() => { domainFilter = null; go('domains'); }"><b>◆</b><span>领域方案库<small>行业标准方案</small></span></UiButton>
+          <UiButton :class="page === 'styleLibrary' ? 'active' : ''" @click="go('styleLibrary')"><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></UiButton>
+        </div>
+        <template v-if="page === 'styleLibrary'">
+          <small>样式库导航</small>
+          <UiButton :class="page === 'styleLibrary' ? 'active' : ''" @click="go('styleLibrary')"><b>❖</b>全部页面样式</UiButton>
+        </template>
+        <template v-else-if="['domains','domainDetail','deviceTemplates'].includes(page)">
+          <small>方案库导航</small>
+          <UiButton :class="page === 'domains' ? 'active' : ''" @click="() => { domainFilter = null; go('domains'); }"><b>▦</b>领域方案</UiButton>
+          <UiButton :class="page === 'deviceTemplates' ? 'active' : ''" @click="go('deviceTemplates')"><b>⬡</b>设备模版</UiButton>
+          <small>领域分类</small>
+          <div v-for="d in domainOptions" :key="d.id" class="domain-nav-row" :class="page === 'domains' && domainFilter === d.id ? ' active' : ''">
+            <UiButton :class="page === 'domains' && domainFilter === d.id ? 'active' : ''" @click="() => { domainFilter = d.id; go('domains'); }"><b>◆</b><span class="domain-nav-name">{{ d.name }}</span></UiButton>
+            <UiButton v-if="d.name !== '其他'" className="domain-nav-del" title="删除领域" @click="async () => { if (!window.confirm(`确定删除领域「${d.name}」?其下的方案将移入「其他」领域。`)) return; try { await fetchDb(`/domains/${d.id}`, { method: 'DELETE' }); notify('已删除领域,相关方案已归入「其他」'); await handleDomainDeleted(d.id); } catch (e) { notify('删除失败:' + e.message); } }">×</UiButton>
+          </div>
+          <div v-if="addingDomain" class="domain-nav-row add">
+            <UiInput className="domain-nav-input" autoFocus :value="newDomainName" placeholder="领域名称" @change="(e) => (newDomainName = e.target.value)" @keydown="async (e) => { if (e.key === 'Enter') { await doAddDomain(); } else if (e.key === 'Escape') { addingDomain = false; newDomainName = ''; } }" />
+            <UiButton className="domain-nav-ok" title="确认" @click="doAddDomain">✓</UiButton>
+            <UiButton className="domain-nav-del" title="取消" @click="() => { addingDomain = false; newDomainName = ''; }">×</UiButton>
+          </div>
+          <div v-else class="domain-nav-add-cta">
+            <UiButton className="domain-nav-add" @click="() => { addingDomain = true; newDomainName = ''; }"><b>+</b>新增领域</UiButton>
+          </div>
+        </template>
+        <template v-else>
+          <small>站点列表</small>
+          <UiButton :class="page === 'projects' ? 'active' : ''" @click="go('projects')"><b>▦</b>站点列表</UiButton>
+          <small>站点详情</small>
+          <UiSelect className="site-switcher" :value="siteId ?? ''" @change="(e) => { const v = e.target.value; if (v) { const t = (['projects','domains','domainDetail','styleLibrary','deviceTemplates'].includes(page)) ? 'basic' : page; go(t, Number(v)); } }">
+            <option value="">选择站点…</option>
+            <option v-for="s in sites" :key="s.id" :value="s.id">{{ s.name || '未命名站点' }}</option>
+          </UiSelect>
+          <small>生成向导</small>
+          <UiButton v-for="item in SITE_EDIT_STEPS" :key="item" :class="page === item ? 'active' : ''" @click="go(item)"><em>{{ SITE_EDIT_STEPS.indexOf(item) + 1 }}</em>{{ meta[item].label }}</UiButton>
+          <small>方案交付</small>
+          <UiButton :class="page === 'result' ? 'active' : ''" @click="go('result')"><b>◫</b>站点结果</UiButton>
+          <UiButton :class="page === 'preview' ? 'active' : ''" @click="go('preview')"><b>▣</b>页面预览</UiButton>
+        </template>
+        <div class="side-help">
+          <b>内部工具 · v0.3</b>
+          <span class="model-trigger" @click="modelOpen = !modelOpen">Wordbuddy AI · {{ model }}<span class="caret">▾</span></span>
+          <template v-if="modelOpen">
+            <div class="model-backdrop" @click="modelOpen = false" />
+            <div class="model-popover">
+              <div class="model-popover-head"><span>Wordbuddy AI 模型</span><small>仅免费模型可选</small></div>
+              <label v-for="m in MODELS" :key="m.name" class="model-row" :class="m.rate === '0.00x' ? '' : ' disabled'">
+                <UiInput type="radio" name="sideModel" :disabled="m.rate !== '0.00x'" :checked="model === m.name" @change="() => { model = m.name; modelOpen = false; }" />
+                <span class="model-name">{{ m.name }}</span>
+                <span class="model-tag">{{ m.tag || (m.rate === '0.00x' ? '免费' : '') }}</span>
+                <span class="model-rate">{{ m.rate }}</span>
+              </label>
+            </div>
+          </template>
+        </div>
+      </aside>
+      <main class="workspace main">
+        <div class="crumb">iStation StationBuilder / <b>{{ ['domains','domainDetail','styleLibrary','deviceTemplates','toolSettings','domainSettings','monitor-dashboard','twin-model','ops-template','knowledge-template'].includes(page) ? meta[page].label : page === 'projects' ? '站点列表' : page === 'siteEdit' ? `站点管理 / 站点编辑 / ${siteEditMeta.label}` : `站点详情 / ${meta[page].label}` }}</b></div>
+        <div class="page-heading">
+          <div>
+            <h1>{{ page === 'siteEdit' ? siteEditMeta.label : meta[page].label }}</h1>
+            <p>{{ page === 'siteEdit' ? siteEditMeta.subtitle : meta[page].subtitle }}</p>
+          </div>
+          <div>
+            <label v-if="page === 'siteEdit'" class="site-edit-switcher"><span>当前站点</span>
+              <UiSelect :value="siteId ?? ''" @change="async (e) => { const id = Number(e.target.value); if (id === siteId) return; try { await saveCurrentConfiguration(); await loadSite(id); siteEditSection = 'basic'; } catch (error) { notify('切换失败:' + error.message); return; } window.history.replaceState({}, '', `?page=siteEdit&id=${id}`); }">
+                <option v-for="item in sites" :key="item.id" :value="item.id">{{ item.name || '未命名站点' }}</option>
+              </UiSelect>
+            </label>
+            <PrimaryButton v-if="page === 'domains' && openDomainTemplateId == null" @click="openDomainTemplateId = 'create'"><UploadOutlined /> 导入领域方案</PrimaryButton>
+            <PrimaryButton v-if="page === 'domains' && openDomainTemplateId != null" secondary @click="openDomainTemplateId = null">← 返回方案列表</PrimaryButton>
+            <PrimaryButton v-if="domainFilter != null && page === 'domains' && openDomainTemplateId == null" secondary @click="domainFilter = null">清除领域筛选:{{ domainOptions.find((d) => d.id === domainFilter)?.name || '' }}</PrimaryButton>
+            <PrimaryButton v-if="page === 'domainSettings'" @click="() => { newDomainName = ''; addingDomain = true; }">+ 新增领域</PrimaryButton>
+          </div>
+        </div>
+        <SiteEditTabs v-if="page === 'siteEdit'" :section="siteEditSection" :onChange="(s) => changeConfigurationStep(s)" />
+        <Steps v-if="SITE_EDIT_STEPS.includes(page)" :page="page" :go="go" />
+        <div class="content">
+          <Login v-if="page === 'login'" :on-login="() => go('projects')" />
+          <Projects v-if="page === 'projects'" :sites="sites" :domains="domainOptions" :loading="loading" :on-save="saveHierarchySite" :on-configure="(id) => go('siteEdit', id)" :on-delete="deleteSites" :notify="notify" />
+          <Dashboard v-if="page === 'dashboard'" />
+          <ImagePage v-if="page === 'report-dashboard'" src="/t/stationbuilder/ext-pages/report-dashboard.jpg" />
+          <ImagePage v-if="page === 'report-doc'" src="/t/stationbuilder/ext-pages/report-doc.jpg" />
+          <ImagePage v-if="page === 'basic-components'" src="/t/stationbuilder/ext-pages/basic-components.jpg" />
+          <ImagePage v-if="page === 'func-components'" src="/t/stationbuilder/ext-pages/func-components.jpg" />
+          <ImagePage v-if="page === 'filters'" src="/t/stationbuilder/ext-pages/filters.jpg" />
+          <ImagePage v-if="page === 'datasource'" src="/t/stationbuilder/ext-pages/datasource.jpg" />
+          <CompanyDomainLibrary v-if="(page === 'domains' || page === 'domainDetail') && openDomainTemplateId == null" :go="go" :notify="notify" :on-open="(id) => (openDomainTemplateId = id)" :domain-filter="domainFilter" :domain-options="domainOptions" />
+          <DomainDetail v-if="(page === 'domains' || page === 'domainDetail') && openDomainTemplateId != null" :id="openDomainTemplateId === 'create' ? null : openDomainTemplateId" :go="go" :notify="notify" :on-back="() => (openDomainTemplateId = null)" :on-created="(nid) => (openDomainTemplateId = nid)" :view-complete-prompt="viewDtCompletePrompt" :view-complete-assets-prompt="viewDtCompleteAssetsPrompt" :view-complete-cubes-prompt="viewDtCompleteCubesPrompt" :page-styles="pageStyles" :on-edit-assets="openDomainDeviceTemplates" :on-open-device-template="openDeviceTemplateEditor" :on-create-site="createSiteFromDomainTemplate" />
+          <StyleLibrary v-if="page === 'styleLibrary'" :page-styles="pageStyles" :load-page-styles="loadPageStyles" :notify="notify" />
+          <DeviceTemplates v-if="page === 'deviceTemplates'" :notify="notify" :on-view-cp-prompt="viewDeviceCpCompletePrompt" :go="go" :site-id="siteId" :domain-options="domainOptions" :initial-domain="deviceTemplateDomain" />
+          <ResourcePlaceholder v-if="page === 'monitor-dashboard'" title="监控仪表板" />
+          <ResourcePlaceholder v-if="page === 'twin-model'" title="数字孪生模型" />
+          <ResourcePlaceholder v-if="page === 'ops-template'" title="运维模板" />
+          <ResourcePlaceholder v-if="page === 'knowledge-template'" title="知识模板" />
+          <ToolSettings v-if="page === 'toolSettings'" :model="model" :on-model-change="(v) => (model = v)" :notify="notify" />
+          <DomainSettings v-if="page === 'domainSettings'" :domains="domainOptions" :notify="notify" :on-deleted="handleDomainDeleted" />
+          <SiteConfigurationContext v-if="page === 'siteEdit' && site" :site="site" :on-back="backToHierarchy" />
+          <div v-if="page === 'basic' || (page === 'siteEdit' && siteEditSection === 'basic')" class="station-overview-layout"><Basic :site="site" :domains="domains" :on-change="saveSiteBasic" :on-add-domain="addDomain" :on-upload="uploadFile" :saving="saving" :notify="notify" :on-view-complete-prompt="viewCompletePrompt" :go="go" :site-id="siteId" :site-hierarchy="siteHierarchy" /></div>
+          <div v-if="(page === 'assets' || (page === 'siteEdit' && siteEditSection === 'assets')) && site" class="station-unified-assets"><ManualAssetConfiguration :key="site.id" :site="site" :notify="notify" :on-site-change="(s) => (site = s)" :register-save="(save) => (assetSaveRef = save)" /></div>
+          <div v-if="page === 'opsflow' || (page === 'siteEdit' && siteEditSection === 'opsflow')" class="station-opsflow-blank" aria-label="运维流程配置预留区域" />
+          <PageConfig v-if="(page === 'pageConfig' || (page === 'siteEdit' && siteEditSection === 'pageConfig')) && site" :site="site" :leaves="muLeaves" :on-mu-change="saveMu" :on-page-config-change="savePageConfiguration" :on-site-change="(s) => (site = s)" :render-domain-dashboard="renderDomainDashboard" :render-dashboard="renderDashboard" :notify="notify" :register-save="(save) => (pageSaveRef = save)" />
+          <Functions v-if="(page === 'functions' || (page === 'siteEdit' && siteEditSection === 'functions')) && site" :site="site" :leaves="muLeaves" :on-site-change="(s) => (site = s)" :notify="notify" :register-save="(save) => (menuSaveRef = save)" />
+          <Generate v-if="page === 'generate'" :site="site" :on-site-change="(s) => (site = s)" :notify="notify" :view-prompt="viewPrompt" :view-site-prompt="viewSitePrompt" />
+          <Result v-if="page === 'result'" :site="site" :go="go" :set-modal="(m) => (modal = m)" :view-prompt="viewPrompt" :notify="notify" :open-feedback="openFeedback" :view-site-prompt="viewSitePrompt" />
+          <Preview v-if="page === 'preview'" :site="site" :tab="previewTab" :set-tab="(t) => (previewTab = t)" :set-modal="(m) => (modal = m)" :notify="notify" />
+        </div>
+        <div v-if="page === 'siteEdit' || SITE_EDIT_STEPS.includes(page)" class="sticky-actions station-config-footer">
+          <PrimaryButton secondary :disabled="configurationSaving || saving" @click="() => { const section = page === 'siteEdit' ? siteEditSection : page; const i = SITE_EDIT_STEPS.indexOf(section); if (i > 0) changeConfigurationStep(SITE_EDIT_STEPS[i - 1]); else backToHierarchy(); }">上一步</PrimaryButton>
+          <div style="display: flex; gap: 12px">
+            <PrimaryButton secondary :disabled="configurationSaving || saving" @click="saveConfigurationDraft">保存草稿</PrimaryButton>
+            <PrimaryButton :disabled="configurationSaving || saving" @click="nextConfigurationStep">{{ (page === 'siteEdit' ? siteEditSection : page) === 'functions' ? '保存并进入生成确认' : '下一步' }}</PrimaryButton>
+          </div>
+        </div>
+        <div v-if="page === 'generate'" class="sticky-actions">
+          <PrimaryButton secondary @click="go('siteEdit', site?.id)">返回站点配置</PrimaryButton>
+        </div>
+      </main>
+    </div>
+    <UiOverlay v-if="addingDomain && page === 'domainSettings'" className="dlg-backdrop" :onMouseDown="() => { addingDomain = false; newDomainName = ''; }">
+      <div class="dlg-box domain-create-dialog" @mousedown.stop>
+        <h2>新增领域</h2>
+        <p>创建后可在领域方案和站点基本信息中直接选择。</p>
+        <UiInput autoFocus :value="newDomainName" maxLength="6" placeholder="请输入领域名称(最多 6 个字)" @change="(e) => (newDomainName = e.target.value)" @keydown="async (e) => { if (e.key === 'Enter') await doAddDomain(); if (e.key === 'Escape') { addingDomain = false; newDomainName = ''; } }" />
+        <UiActions className="dlg-actions">
+          <PrimaryButton secondary @click="() => { addingDomain = false; newDomainName = ''; }">取消</PrimaryButton>
+          <PrimaryButton :disabled="!newDomainName.trim()" @click="doAddDomain">创建</PrimaryButton>
+        </UiActions>
+      </div>
+    </UiOverlay>
+    <ModalComp v-if="modal" :type="modal" :close="() => (modal = '')" :notify="notify" :prompt-data="promptData" :feedback-cube="feedbackCube" :refine-cube="refineCube" :refining="refining" :refine-log="refineLog" />
+    <UiButton v-if="!aiOpen && !manualAssetActive && !domainArea" ref="fabRef" className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
+      :style="fabPos ? { left: fabPos.x + 'px', top: page === 'siteEdit' ? Math.min(fabPos.y, window.innerHeight - 132) + 'px' : fabPos.y + 'px', right: 'auto', bottom: 'auto' } : page === 'siteEdit' ? { bottom: '76px' } : undefined"
+      @pointerdown="onFabPointerDown" @pointermove="onFabPointerMove" @pointerup="onFabPointerUp" @dblclick="resetFab">
+      <img src="/t/stationbuilder/ai-icon.png" alt="AI 助手" className="ai-fab-icon" draggable="false" />
+    </UiButton>
+    <div v-if="aiOpen && !manualAssetActive && !domainArea" class="ai-dialog">
+      <header><span class="ai-dialog-logo">✦</span><div class="ai-dialog-title"><b>StationBuilder AI</b><small>模型 · {{ model }}</small></div><UiButton className="ai-close" @click="aiOpen = false">×</UiButton></header>
+      <div class="ai-messages">
+        <div v-if="aiMessages.length === 0" class="ai-welcome">
+          <div class="ai-welcome-intro"><span>✦</span><div><b>我可以帮你建设场站</b><p>进入对应页面使用 AI 功能,或直接在下方提问。</p></div></div>
+          <div class="ai-capability-list">
+            <div><i>1</i><span><b>完善站点需求</b><small>站点编辑 → 站点基本属性</small></span></div>
+            <div><i>2</i><span><b>推荐设备、数量与命名规则</b><small>站点编辑 → 站点资产设置</small></span></div>
+            <div><i>3</i><span><b>规划并生成 Cube 页面</b><small>站点编辑 → 功能与菜单</small></span></div>
+            <div><i>4</i><span><b>检查结果并按意见修改页面</b><small>站点编辑 → 生成确认/页面预览</small></span></div>
+            <div><i>5</i><span><b>补全领域方案与设备采集点</b><small>建站资源 → 领域方案/设备模板</small></span></div>
+          </div>
+          <div class="ai-welcome-tip"><b>你可以问:</b>当前站点还缺少哪些设备或页面?</div>
+        </div>
+        <div v-for="(m, i) in aiMessages" :key="i" class="ai-bubble" :class="m.role === 'user' ? 'me' : 'bot'"><span class="ai-avatar">{{ m.role === 'user' ? 'W' : '✦' }}</span><div class="ai-text">{{ m.content || (m.role === 'assistant' && aiBusy ? '…' : '') }}</div></div>
+        <div ref="aiEndRef" />
+      </div>
+      <footer>
+        <UiInput className="ai-input" :value="aiDraft" placeholder="输入消息,Enter 发送,Shift+Enter 换行" @change="(e) => (aiDraft = e.target.value)" @keydown="(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendAi(); } }" />
+        <UiButton className="ai-send" :disabled="aiBusy || !aiDraft.trim()" @click="sendAi">{{ aiBusy ? '生成中' : '发送' }}</UiButton>
+      </footer>
+    </div>
+  </div>
+</template>
diff --git a/src-vue/components/Dashboard.vue b/src-vue/components/Dashboard.vue
new file mode 100644 (file)
index 0000000..2b21815
--- /dev/null
@@ -0,0 +1,5 @@
+<template>
+  <div class="content dashboard-page">
+    <img src="/t/stationbuilder/dashboard-preview.jpg" alt="仪表盘示意" loading="lazy" />
+  </div>
+</template>
diff --git a/src-vue/components/DashboardThumb.vue b/src-vue/components/DashboardThumb.vue
new file mode 100644 (file)
index 0000000..4c09588
--- /dev/null
@@ -0,0 +1,48 @@
+<script setup>
+import { ref, watch, onMounted } from "vue";
+import { loadRenderer, fetchDb } from "../lib/api.js";
+
+const props = defineProps({
+  siteId: { type: Number, required: true },
+  cube: { type: Object, required: true },
+  fixedHeight: { type: Number, default: undefined },
+});
+
+const outer = ref(null);
+const inner = ref(null);
+
+async function render() {
+  const o = outer.value;
+  const i = inner.value;
+  if (!o || !i) return;
+  if (!props.cube.id) {
+    i.innerHTML = '<div class="thumb-empty">未生成</div>';
+    return;
+  }
+  try {
+    const json = await fetchDb(`/sites/${props.siteId}/cubes/${props.cube.id}/file`);
+    const renderDashboard = await loadRenderer();
+    renderDashboard(json, i, {});
+    const w = o.clientWidth || 300;
+    const pw = json.grid?.pageWidth || 1920;
+    const rootEl = i.firstElementChild;
+    const rh = rootEl ? rootEl.offsetHeight : json.grid?.pageHeight || 1080;
+    const s = Math.max(0.02, props.fixedHeight ? Math.min(w / pw, props.fixedHeight / rh) : w / pw);
+    i.style.height = `${rh}px`;
+    i.style.transform = `scale(${s})`;
+    i.style.transformOrigin = "top left";
+    if (o) o.style.height = `${props.fixedHeight || rh * s}px`;
+  } catch (e) {
+    i.innerHTML = '<div style="padding:8px;color:var(--ant-color-text-tertiary)">预览不可用</div>';
+  }
+}
+
+onMounted(render);
+watch(() => [props.cube.id, props.cube.generated_at, props.fixedHeight], render);
+</script>
+
+<template>
+  <div ref="outer" class="dashboard-thumb-live" style="overflow: hidden">
+    <div ref="inner" style="width: 1920px; height: 1080px; position: relative"></div>
+  </div>
+</template>
diff --git a/src-vue/components/DeviceTemplateCard.js b/src-vue/components/DeviceTemplateCard.js
new file mode 100644 (file)
index 0000000..882c035
--- /dev/null
@@ -0,0 +1,475 @@
+// Shared device-template card + helpers (ported from app/page.tsx).
+// Extracted so both the DomainSettings page and the DeviceTemplates page can reuse it.
+import { ref, computed, h } from "vue";
+import { Drawer, Button as AntButton } from "ant-design-vue";
+import {
+  EditOutlined, DeleteOutlined, CopyOutlined, PlusOutlined, RobotOutlined,
+  SearchOutlined, ControlOutlined,
+} from "@ant-design/icons-vue";
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiActions from "../ui/UiActions.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+import { fetchDb } from "../lib/api.js";
+import { useConfirm } from "../pages/domain/useConfirm.js";
+import { useStreamComplete, streamInto } from "../pages/domain/domainCommon.js";
+
+// React local `Button` (PrimaryButton primary/default + "button" class).
+const Button = PrimaryButton;
+
+const CP_RANGE_PRESETS = ["0~100", "0~1000", "-20~80", "-40~85", "0~50", "0~5", "0~1", "0~100%"];
+
+function fileToBase64(file) {
+  return new Promise((resolve, reject) => {
+    const r = new FileReader();
+    r.onload = () => {
+      const buf = new Uint8Array(r.result);
+      let bin = "";
+      for (const b of buf) bin += String.fromCharCode(b);
+      resolve({ filename: file.name, mime: file.type || "application/octet-stream", data: btoa(bin) });
+    };
+    r.onerror = reject;
+    r.readAsArrayBuffer(file);
+  });
+}
+
+const DeviceImageField = {
+  props: { imageUrl: { type: String, default: null }, onPick: { type: Function, default: () => {} } },
+  setup(p) {
+    const inputRef = ref(null);
+    return () =>
+      h("div", { class: "device-img-field", onClick: () => inputRef.value && inputRef.value.click() }, [
+        p.imageUrl
+          ? h("img", { src: p.imageUrl, alt: "设备图片" })
+          : h("span", { class: "device-img-empty" }, "+ 点击上传设备图片"),
+        h("input", {
+          ref: inputRef,
+          type: "file",
+          accept: "image/*",
+          hidden: true,
+          onChange: (e) => {
+            const f = e.target.files && e.target.files[0];
+            if (f) p.onPick(f);
+            e.target.value = "";
+          },
+        }),
+      ]);
+  },
+};
+
+const DeviceTemplateCard = {
+  props: {
+    dev: { type: Object, default: () => ({}) },
+    notify: { type: Function, default: () => {} },
+    onChange: { type: Function, default: () => {} },
+    onBack: { type: Function, default: () => {} },
+    onViewCpPrompt: { type: Function, default: () => {} },
+  },
+  setup(p) {
+    const name = ref(p.dev.name);
+    const description = ref(p.dev.description || "");
+    const imgPreview = ref(p.dev.image_path ? `/api/db/device-templates/${p.dev.id}/image` : null);
+    const imgB64 = ref(null);
+    const cps = ref((p.dev.collection_points || []).map((c) => ({ ...c, key: c.id, checked: false })));
+    const aiOpen = ref(false);
+    const abortRef = ref(null);
+    const { confirm, el: confirmEl } = useConfirm();
+
+    const onPick = async (f) => {
+      imgPreview.value = URL.createObjectURL(f);
+      const b = await fileToBase64(f);
+      imgB64.value = { filename: b.filename, mime: b.mime, data: b.data };
+    };
+    const save = async () => {
+      const body = { name: name.value, description: description.value };
+      if (imgB64.value) body.image = imgB64.value;
+      await fetchDb(`/device-templates/${p.dev.id}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      });
+      p.notify("设备已保存");
+      p.onChange();
+    };
+    const delDev = async () => {
+      if (!(await confirm("删除该设备模板?"))) return;
+      await fetchDb(`/device-templates/${p.dev.id}`, { method: "DELETE" });
+      p.onChange();
+      p.onBack();
+    };
+    const addCpRow = () => cps.value.push({ key: -(Date.now()), name: "", collect_note: "", unit: "", value_range: "", description: "", checked: false });
+    const updCp = (k, field, val) => (cps.value = cps.value.map((c) => (c.key === k ? { ...c, [field]: val } : c)));
+    const toggleCp = (k) => (cps.value = cps.value.map((c) => (c.key === k ? { ...c, checked: !c.checked } : c)));
+    const allChecked = computed(() => cps.value.length > 0 && cps.value.every((c) => c.checked));
+    const toggleAll = () => (cps.value = cps.value.map((c) => ({ ...c, checked: !allChecked.value })));
+    const delCpRows = async () => {
+      const sel = cps.value.filter((c) => c.checked);
+      if (sel.length === 0) { p.notify("请先勾选要删除的采集点"); return; }
+      if (!(await confirm(`确定删除选中的 ${sel.length} 个采集点?`))) return;
+      for (const c of sel) if (c.id) await fetchDb(`/collection-points/${c.id}`, { method: "DELETE" });
+      cps.value = cps.value.filter((c) => !c.checked);
+      p.onChange();
+    };
+    const saveCpRows = async () => {
+      try {
+        for (const c of cps.value) {
+          const payload = { name: c.name, collect_note: c.collect_note, unit: c.unit, value_range: c.value_range, description: c.description };
+          if (c.id) await fetchDb(`/collection-points/${c.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
+          else await fetchDb(`/device-templates/${p.dev.id}/collection-points`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
+        }
+        p.notify("采集点已保存");
+        p.onChange();
+      } catch (e) {
+        p.notify("保存失败:" + (e && e.message ? e.message : e));
+      }
+    };
+
+    const attrs = ref((p.dev.attributes || []).map((a) => ({ ...a, key: a.id, checked: false })));
+    const addAttrRow = () => attrs.value.push({ key: -(Date.now()), keyword: "", name: "", value: "", unit: "", checked: false });
+    const updAttr = (k, field, val) => (attrs.value = attrs.value.map((a) => (a.key === k ? { ...a, [field]: val } : a)));
+    const toggleAttr = (k) => (attrs.value = attrs.value.map((a) => (a.key === k ? { ...a, checked: !a.checked } : a)));
+    const allAttrChecked = computed(() => attrs.value.length > 0 && attrs.value.every((a) => a.checked));
+    const toggleAllAttr = () => (attrs.value = attrs.value.map((a) => ({ ...a, checked: !allAttrChecked.value })));
+    const delAttrRows = async () => {
+      const sel = attrs.value.filter((a) => a.checked);
+      if (sel.length === 0) { p.notify("请先勾选要删除的属性"); return; }
+      if (!(await confirm(`确定删除选中的 ${sel.length} 个属性?`))) return;
+      for (const a of sel) if (a.id) await fetchDb(`/attributes/${a.id}`, { method: "DELETE" });
+      attrs.value = attrs.value.filter((a) => !a.checked);
+      p.onChange();
+    };
+    const saveAttrRows = async () => {
+      try {
+        for (const a of attrs.value) {
+          const payload = { keyword: a.keyword, name: a.name, value: a.value, unit: a.unit };
+          if (a.id) await fetchDb(`/attributes/${a.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
+          else await fetchDb(`/device-templates/${p.dev.id}/attributes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
+        }
+        p.notify("属性已保存");
+        p.onChange();
+      } catch (e) {
+        p.notify("保存失败:" + (e && e.message ? e.message : e));
+      }
+    };
+
+    const alarmRules = ref(
+      Array.isArray(p.dev.alarm_rules) && p.dev.alarm_rules.length
+        ? p.dev.alarm_rules
+        : [
+            { id: 1, name: "设备离线", level: "高", category: "故障", method: "自动", description: "设备连续 5 分钟无数据上报", enabled: true, sensitivityType: "duration", sensitivityValue: "5", typeName: "离线报警", triggers: [{ id: 11, name: "离线判断", source: "当前设备", compareType: "状态", point: "在线状态", operator: "=", threshold: "0" }] },
+            { id: 2, name: "电压过高", level: "高", category: "报警", method: "自动", description: "供电电压超过额定上限", enabled: true, sensitivityType: "consecutive", sensitivityValue: "1", typeName: "阈值报警", triggers: [{ id: 21, name: "电压上限", source: "当前设备", compareType: "数值", point: "供电电压", operator: ">", threshold: "240" }] },
+          ]
+    );
+    const alarmQuery = ref("");
+    const selectedAlarmIds = ref(new Set());
+    const alarmDraft = ref(null);
+    const alarmFiltered = computed(() =>
+      alarmRules.value.filter((rule) => !alarmQuery.value.trim() || `${rule.name} ${rule.description || ""}`.toLowerCase().includes(alarmQuery.value.trim().toLowerCase()))
+    );
+    const newAlarmDraft = () => ({
+      id: null, name: "", level: "高", category: "报警", method: "自动", description: "", enabled: true,
+      typeName: "阈值报警", sensitivityType: "consecutive", sensitivityValue: "1",
+      triggers: [{ id: -Date.now(), name: "规则 1", source: "当前设备", compareType: "数值", point: cps.value[0] ? cps.value[0].name : "", operator: ">", threshold: "" }],
+    });
+    const editAlarm = (rule) => (alarmDraft.value = { ...rule, triggers: (rule.triggers || []).map((item) => ({ ...item })) });
+    const saveAlarm = () => {
+      if (!alarmDraft.value || !alarmDraft.value.name || !alarmDraft.value.name.trim()) { p.notify("请填写报警名称"); return; }
+      if (!alarmDraft.value.triggers || !alarmDraft.value.triggers.length) { p.notify("请至少添加一条触发规则"); return; }
+      const isCreate = alarmDraft.value.id == null;
+      const saved = { ...alarmDraft.value, id: alarmDraft.value.id != null ? alarmDraft.value.id : Date.now() };
+      alarmRules.value = isCreate ? [...alarmRules.value, saved] : alarmRules.value.map((rule) => (rule.id === saved.id ? saved : rule));
+      alarmDraft.value = null;
+      p.notify(isCreate ? "报警规则已新增" : "报警规则已更新");
+    };
+    const copyAlarm = (rule) => {
+      const copied = { ...rule, id: Date.now(), name: `${rule.name} - 副本`, triggers: (rule.triggers || []).map((item, index) => ({ ...item, id: -(Date.now() + index) })) };
+      alarmRules.value = [...alarmRules.value, copied];
+      p.notify("已复制报警规则");
+    };
+    const removeAlarm = async (rule) => {
+      if (!(await confirm(`确定删除报警规则「${rule.name}」?`))) return;
+      alarmRules.value = alarmRules.value.filter((item) => item.id !== rule.id);
+      const next = new Set(selectedAlarmIds.value); next.delete(rule.id); selectedAlarmIds.value = next;
+      p.notify("报警规则已删除");
+    };
+    const toggleAlarm = (id) => (alarmRules.value = alarmRules.value.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)));
+    const toggleAlarmSelect = (id) => {
+      const next = new Set(selectedAlarmIds.value);
+      if (next.has(id)) next.delete(id); else next.add(id);
+      selectedAlarmIds.value = next;
+    };
+    const allAlarmsChecked = computed(() => alarmFiltered.value.length > 0 && alarmFiltered.value.every((rule) => selectedAlarmIds.value.has(rule.id)));
+    const toggleAllAlarms = () => {
+      const next = new Set(selectedAlarmIds.value);
+      if (allAlarmsChecked.value) alarmFiltered.value.forEach((rule) => next.delete(rule.id));
+      else alarmFiltered.value.forEach((rule) => next.add(rule.id));
+      selectedAlarmIds.value = next;
+    };
+    const updateAlarmTrigger = (id, field, value) =>
+      (alarmDraft.value = { ...alarmDraft.value, triggers: alarmDraft.value.triggers.map((item) => (item.id === id ? { ...item, [field]: value } : item)) });
+    const addAlarmTrigger = () =>
+      (alarmDraft.value = { ...alarmDraft.value, triggers: [...alarmDraft.value.triggers, { id: -Date.now(), name: `规则 ${alarmDraft.value.triggers.length + 1}`, source: "当前设备", compareType: "数值", point: cps.value[0] ? cps.value[0].name : "", operator: ">", threshold: "" }] });
+    const removeAlarmTrigger = (id) =>
+      (alarmDraft.value = { ...alarmDraft.value, triggers: alarmDraft.value.triggers.filter((item) => item.id !== id) });
+
+    const cpAi = useStreamComplete();
+    const runCpComplete = async () => {
+      if (cpAi.completing.value) return;
+      cpAi.setCompleting(true); cpAi.setLog("");
+      if (abortRef.value) abortRef.value.abort();
+      const controller = new AbortController();
+      abortRef.value = controller;
+      const wd = setTimeout(() => { controller.abort(); cpAi.setCompleting(false); p.notify("AI 采集点补全超时(>10 分钟),请重试"); }, 600000);
+      try {
+        const res = await fetch(`/api/db/device-templates/${p.dev.id}/complete-collection-points`, { method: "POST", signal: controller.signal });
+        if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+        await streamInto(res, cpAi.setLog, () => {});
+        const fresh = await fetchDb(`/device-templates/${p.dev.id}`);
+        cps.value = (fresh.collection_points || []).map((c, i) => ({ ...c, key: c.id || -(i + 1), checked: false }));
+        p.notify("AI 采集点补全已完成(已保存到该设备模板)");
+      } catch (e) {
+        if (e && e.name === "AbortError" || (abortRef.value && abortRef.value.signal.aborted)) {
+          cpAi.setLog((prev) => prev + "\n⏹ 已停止\n");
+          p.notify("AI 采集点补全已停止");
+        } else {
+          p.notify("AI 采集点补全失败:" + (e && e.message ? e.message : e));
+        }
+      } finally {
+        clearTimeout(wd); cpAi.setCompleting(false); abortRef.value = null;
+      }
+    };
+    const stopCpComplete = async () => {
+      if (!cpAi.completing.value || !abortRef.value) return;
+      try { await fetchDb(`/device-templates/${p.dev.id}/complete-collection-points/stop`, { method: "POST" }); } catch {}
+      abortRef.value.abort();
+    };
+    const viewCpCompletePrompt = () => p.onViewCpPrompt(p.dev.id);
+
+    return () => [
+      confirmEl && h(confirmEl),
+      h("div", { class: "device-card" }, [
+        h("div", { class: "device-card-head" }, [
+          h("b", {}, name.value || "(未命名)"),
+          h(UiButton, { class: "text-button", onClick: delDev }, "删除设备"),
+        ]),
+        h("div", { class: "form-grid" }, [
+          h("label", {}, ["设备名称", h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value) })]),
+          h("label", { style: { gridColumn: "1 / -1" } }, ["设备图片", h(DeviceImageField, { imageUrl: imgPreview.value, onPick })]),
+          h("label", { style: { gridColumn: "1 / -1" } }, [
+            "说明",
+            h(UiInput, { textarea: true, value: description.value, onChange: (e) => (description.value = e.target.value), rows: 2, placeholder: "设备说明,可描述其用途,特性,常见使用方式等。" }),
+          ]),
+        ]),
+        h("div", { class: "cp-list" }, [
+          h("div", { class: "cp-list-head" }, [
+            h("b", {}, "采集点"),
+            h("span", {}, [
+              h(UiButton, { class: "text-button", onClick: addCpRow }, "+ 新增一行"),
+              h(UiButton, { class: "text-button", onClick: delCpRows }, "删除选中"),
+              h(UiButton, { class: "text-button", onClick: saveCpRows }, "保存采集点"),
+            ]),
+          ]),
+          h(UiTable, { class: "data-table cp-editor" }, () => [
+            h("thead", {}, h("tr", {}, [
+              h("th", { style: { width: 34 } }, h(UiInput, { type: "checkbox", checked: allChecked.value, onChange: toggleAll })),
+              h("th", {}, "名称"), h("th", {}, "说明"), h("th", {}, "单位"), h("th", {}, "数值范围"), h("th", {}, "描述"),
+            ])),
+            h("tbody", {}, [
+              cps.value.length === 0
+                ? h("tr", {}, h("td", { colSpan: 6, class: "muted" }, "暂无采集点,点「+ 新增一行」添加"))
+                : cps.value.map((c) =>
+                    h("tr", { key: c.key }, [
+                      h("td", {}, h(UiInput, { type: "checkbox", checked: !!c.checked, onChange: () => toggleCp(c.key) })),
+                      h("td", {}, h(UiInput, { value: c.name, onChange: (e) => updCp(c.key, "name", e.target.value), placeholder: "名称" })),
+                      h("td", {}, h(UiInput, { value: c.collect_note || "", onChange: (e) => updCp(c.key, "collect_note", e.target.value), placeholder: "说明" })),
+                      h("td", {}, h(UiInput, { value: c.unit || "", onChange: (e) => updCp(c.key, "unit", e.target.value), placeholder: "单位" })),
+                      h("td", {}, [
+                        h(UiSelect, {
+                          value: CP_RANGE_PRESETS.includes(c.value_range) ? c.value_range : "__custom__",
+                          onChange: (e) => updCp(c.key, "value_range", e.target.value === "__custom__" ? "" : e.target.value),
+                        }, () => [
+                          ...CP_RANGE_PRESETS.map((pp) => h("option", { key: pp, value: pp }, pp)),
+                          h("option", { value: "__custom__" }, "自定义…"),
+                        ]),
+                        !CP_RANGE_PRESETS.includes(c.value_range)
+                          ? h(UiInput, { class: "cp-range-custom", value: c.value_range, onChange: (e) => updCp(c.key, "value_range", e.target.value), placeholder: "如 0~100" })
+                          : null,
+                      ]),
+                      h("td", {}, h(UiInput, { value: c.description || "", onChange: (e) => updCp(c.key, "description", e.target.value), placeholder: "描述" })),
+                    ])
+                  ),
+            ]),
+          ]),
+        ]),
+        h("div", { class: "cp-list" }, [
+          h("div", { class: "cp-list-head" }, [
+            h("b", {}, "属性列表"),
+            h("span", {}, [
+              h(UiButton, { class: "text-button", onClick: addAttrRow }, "+ 新增一行"),
+              h(UiButton, { class: "text-button", onClick: delAttrRows }, "删除选中"),
+              h(UiButton, { class: "text-button", onClick: saveAttrRows }, "保存属性"),
+            ]),
+          ]),
+          h(UiTable, { class: "data-table cp-editor" }, () => [
+            h("thead", {}, h("tr", {}, [
+              h("th", { style: { width: 34 } }, h(UiInput, { type: "checkbox", checked: allAttrChecked.value, onChange: toggleAllAttr })),
+              h("th", {}, "关键字"), h("th", {}, "属性名称"), h("th", {}, "属性值"), h("th", {}, "单位"),
+            ])),
+            h("tbody", {}, [
+              attrs.value.length === 0
+                ? h("tr", {}, h("td", { colSpan: 5, class: "muted" }, "暂无属性,点「+ 新增一行」添加"))
+                : attrs.value.map((a) =>
+                    h("tr", { key: a.key }, [
+                      h("td", {}, h(UiInput, { type: "checkbox", checked: !!a.checked, onChange: () => toggleAttr(a.key) })),
+                      h("td", {}, h(UiInput, { value: a.keyword || "", onChange: (e) => updAttr(a.key, "keyword", e.target.value), placeholder: "关键字" })),
+                      h("td", {}, h(UiInput, { value: a.name || "", onChange: (e) => updAttr(a.key, "name", e.target.value), placeholder: "属性名称" })),
+                      h("td", {}, h(UiInput, { value: a.value || "", onChange: (e) => updAttr(a.key, "value", e.target.value), placeholder: "属性值" })),
+                      h("td", {}, h(UiInput, { value: a.unit || "", onChange: (e) => updAttr(a.key, "unit", e.target.value), placeholder: "单位" })),
+                    ])
+                  ),
+            ]),
+          ]),
+        ]),
+        h("div", { class: "cp-list alarm-rule-block" }, [
+          h("div", { class: "alarm-rule-head" }, [
+            h("b", {}, "报警规则"),
+            h("div", {}, [
+              h("label", { class: "alarm-rule-search" }, [h(SearchOutlined), h(UiInput, { value: alarmQuery.value, onChange: (e) => (alarmQuery.value = e.target.value), placeholder: "报警名称" })]),
+              h(Button, { onClick: () => (alarmDraft.value = newAlarmDraft()) }, [h(PlusOutlined), " 新增"]),
+            ]),
+          ]),
+          h(UiTable, { class: "data-table alarm-rule-table" }, () => [
+            h("thead", {}, h("tr", {}, [
+              h("th", { style: { width: 42 } }, h(UiInput, { type: "checkbox", checked: allAlarmsChecked.value, onChange: toggleAllAlarms })),
+              h("th", {}, "报警名称"), h("th", {}, "报警等级"), h("th", {}, "报警类别"), h("th", {}, "处理方式"), h("th", {}, "报警描述"),
+              h("th", { style: { width: 76 } }, "开关"), h("th", { style: { width: 128 } }, "操作"),
+            ])),
+            h("tbody", {}, [
+              alarmFiltered.value.length === 0
+                ? h("tr", {}, h("td", { colSpan: 8, class: "muted" }, "暂无报警规则,点击“新增”进行配置"))
+                : alarmFiltered.value.map((rule) =>
+                    h("tr", { key: rule.id }, [
+                      h("td", {}, h(UiInput, { type: "checkbox", checked: selectedAlarmIds.value.has(rule.id), onChange: () => toggleAlarmSelect(rule.id) })),
+                      h("td", {}, h(UiButton, { class: "link alarm-name-link", onClick: () => editAlarm(rule) }, rule.name)),
+                      h("td", {}, rule.level),
+                      h("td", {}, rule.category),
+                      h("td", {}, rule.method),
+                      h("td", { class: "alarm-description-cell", title: rule.description }, rule.description || "—"),
+                      h("td", {}, h(UiButton, { type: "button", role: "switch", "aria-checked": rule.enabled, class: rule.enabled ? "alarm-switch on" : "alarm-switch", onClick: () => toggleAlarm(rule.id) }, [h("i")])),
+                      h("td", {}, h("div", { class: "alarm-row-actions" }, [
+                        h(AntButton, { type: "text", title: "编辑", "aria-label": "编辑报警规则", icon: h(EditOutlined), onClick: () => editAlarm(rule) }),
+                        h(AntButton, { type: "text", title: "复制", "aria-label": "复制报警规则", icon: h(CopyOutlined), onClick: () => copyAlarm(rule) }),
+                        h(AntButton, { type: "text", danger: true, title: "删除", "aria-label": "删除报警规则", icon: h(DeleteOutlined), onClick: () => removeAlarm(rule) }),
+                      ])),
+                    ])
+                  ),
+            ]),
+          ]),
+        ]),
+        h("div", { class: "field-assist", style: { marginTop: 16 } }, [
+          h(Button, { secondary: true, onClick: () => (aiOpen.value = true) }, [h(RobotOutlined), " AI 智能补全"]),
+          h("span", { class: "muted" }, "基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加"),
+        ]),
+        h(Drawer, {
+          class: "alarm-rule-drawer",
+          title: alarmDraft.value && alarmDraft.value.id == null ? "新增报警规则" : "编辑报警规则",
+          width: 1000,
+          open: !!alarmDraft.value,
+          onClose: () => (alarmDraft.value = null),
+          footer: () => h("div", { class: "node-drawer-footer" }, [
+            h(Button, { secondary: true, onClick: () => (alarmDraft.value = null) }, "取消"),
+            h(Button, { onClick: saveAlarm }, "确定"),
+          ]),
+        }, () =>
+          alarmDraft.value
+            ? h("div", { class: "alarm-rule-form" }, [
+                h("section", {}, [
+                  h("h3", {}, "基本信息"),
+                  h("div", { class: "alarm-form-grid" }, [
+                    h("label", {}, [h("span", {}, [h("i", {}, "*"), " 报警名称"]), h(UiInput, { value: alarmDraft.value.name, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, name: e.target.value }), placeholder: "请输入报警名称" })]),
+                    h("label", {}, [h("span", {}, [h("i", {}, "*"), " 报警等级"]), h(UiSelect, { value: alarmDraft.value.level, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, level: e.target.value }) }, () => [h("option", {}, "紧急"), h("option", {}, "高"), h("option", {}, "中"), h("option", {}, "低")])]),
+                    h("label", { class: "wide" }, [h("span", {}, "报警描述"), h(UiInput, { value: alarmDraft.value.description, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, description: e.target.value }), placeholder: "请输入报警描述" })]),
+                  ]),
+                ]),
+                h("section", {}, [
+                  h("h3", {}, "类别设定"),
+                  h("div", { class: "alarm-form-grid" }, [
+                    h("label", {}, [h("span", {}, [h("i", {}, "*"), " 类别名称"]), h(UiSelect, { value: alarmDraft.value.category, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, category: e.target.value }) }, () => [h("option", {}, "报警"), h("option", {}, "故障"), h("option", {}, "事件"), h("option", {}, "通知")])]),
+                    h("label", {}, [h("span", {}, [h("i", {}, "*"), " 类型名称"]), h(UiSelect, { value: alarmDraft.value.typeName, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, typeName: e.target.value }) }, () => [h("option", {}, "阈值报警"), h("option", {}, "离线报警"), h("option", {}, "状态报警"), h("option", {}, "组合报警")])]),
+                  ]),
+                ]),
+                h("section", {}, [
+                  h("h3", {}, "灵敏度设定"),
+                  h("div", { class: "alarm-sensitivity" }, [
+                    h("span", {}, [h("i", {}, "*"), " 灵敏度类型"]),
+                    h("label", {}, [h(UiInput, { type: "radio", name: "alarm-sensitivity", checked: alarmDraft.value.sensitivityType === "duration", onChange: () => (alarmDraft.value = { ...alarmDraft.value, sensitivityType: "duration" }) }), " 持续时间"]),
+                    h(UiInput, { type: "number", min: "1", value: alarmDraft.value.sensitivityType === "duration" ? alarmDraft.value.sensitivityValue : "", disabled: alarmDraft.value.sensitivityType !== "duration", onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, sensitivityValue: e.target.value }) }),
+                    h("em", {}, "秒,报警一次"),
+                    h("label", {}, [h(UiInput, { type: "radio", name: "alarm-sensitivity", checked: alarmDraft.value.sensitivityType === "consecutive", onChange: () => (alarmDraft.value = { ...alarmDraft.value, sensitivityType: "consecutive" }) }), " 连续触发"]),
+                    h(UiInput, { type: "number", min: "1", value: alarmDraft.value.sensitivityType === "consecutive" ? alarmDraft.value.sensitivityValue : "", disabled: alarmDraft.value.sensitivityType !== "consecutive", onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, sensitivityValue: e.target.value }) }),
+                    h("em", {}, "次,报警一次"),
+                  ]),
+                ]),
+                h("section", {}, [
+                  h("h3", {}, "触发规则设定"),
+                  h("div", { class: "alarm-trigger-panel" }, [
+                    h("header", {}, [
+                      h("b", {}, "触发规则列表"),
+                      h("span", {}, [
+                        h(Button, { secondary: true, onClick: () => p.notify("规则策略设定将在后续接入") }, [h(ControlOutlined), " 规则策略设定"]),
+                        h(Button, { onClick: addAlarmTrigger }, [h(PlusOutlined), " 新增"]),
+                      ]),
+                    ]),
+                    h(UiTable, { class: "data-table alarm-trigger-table" }, () => [
+                      h("thead", {}, h("tr", {}, [
+                        h("th", {}, "名称"), h("th", {}, "报警来源"), h("th", {}, "比较类型"), h("th", {}, "监测点"), h("th", {}, "运算符号"), h("th", {}, "阈值"), h("th", {}, "操作"),
+                      ])),
+                      h("tbody", {}, alarmDraft.value.triggers.map((trigger) =>
+                        h("tr", { key: trigger.id }, [
+                          h("td", {}, h(UiInput, { value: trigger.name, onChange: (e) => updateAlarmTrigger(trigger.id, "name", e.target.value) })),
+                          h("td", {}, h(UiSelect, { value: trigger.source, onChange: (e) => updateAlarmTrigger(trigger.id, "source", e.target.value) }, () => [h("option", {}, "当前设备"), h("option", {}, "关联设备")])),
+                          h("td", {}, h(UiSelect, { value: trigger.compareType, onChange: (e) => updateAlarmTrigger(trigger.id, "compareType", e.target.value) }, () => [h("option", {}, "数值"), h("option", {}, "状态"), h("option", {}, "字符串")])),
+                          h("td", {}, h(UiSelect, { value: trigger.point, onChange: (e) => updateAlarmTrigger(trigger.id, "point", e.target.value) }, () => [h("option", { value: "" }, "请选择"), ...cps.value.map((point) => h("option", { key: point.key, value: point.name }, point.name || "未命名采集点"))])),
+                          h("td", {}, h(UiSelect, { value: trigger.operator, onChange: (e) => updateAlarmTrigger(trigger.id, "operator", e.target.value) }, () => [h("option", {}, ">"), h("option", {}, ">="), h("option", {}, "<"), h("option", {}, "<="), h("option", {}, "="), h("option", {}, "!=")])),
+                          h("td", {}, h(UiInput, { value: trigger.threshold, onChange: (e) => updateAlarmTrigger(trigger.id, "threshold", e.target.value), placeholder: "请输入" })),
+                          h("td", {}, h(AntButton, { type: "text", danger: true, title: "删除触发规则", icon: h(DeleteOutlined), onClick: () => removeAlarmTrigger(trigger.id) })),
+                        ])
+                      )),
+                    ]),
+                    alarmDraft.value.triggers.length === 0 ? h("div", { class: "alarm-trigger-empty" }, "暂无数据") : null,
+                  ]),
+                ]),
+              ])
+            : null
+        ),
+        h(Drawer, {
+          title: "AI 智能补全",
+          width: 440,
+          open: aiOpen.value,
+          onClose: () => (aiOpen.value = false),
+        }, () =>
+          h("div", { class: "ai-assist-drawer" }, [
+            h("div", { class: "ai-assist-intro" }, [h(RobotOutlined), h("div", {}, [h("h3", {}, "补充采集点"), h("p", {}, "AI 将根据设备名称、所属领域和说明生成合适的采集点,并以追加模式写入该设备模板。")])]),
+            h("p", { class: "hint" }, "为当前设备模板补充一套合适的采集点(追加模式,不覆盖已手动录入的采集点)。"),
+            h(UiActions, { class: "drawer-actions" }, () => [
+              h(Button, { disabled: cpAi.completing.value, onClick: runCpComplete }, cpAi.completing.value ? "采集中…" : "开始补全"),
+              cpAi.completing.value ? h(Button, { secondary: true, onClick: stopCpComplete }, "停止") : null,
+              h(Button, { secondary: true, onClick: viewCpCompletePrompt }, "查看提示词"),
+            ]),
+            (cpAi.completing.value || cpAi.log.value)
+              ? h("div", { class: "modal-console" }, [
+                  h("div", { class: "modal-console-head" }, "AI 智能补全"),
+                  h("pre", { ref: cpAi.ref, class: "modal-console-body" }, cpAi.log.value || "(等待 AI 分析…)"),
+                ])
+              : null,
+          ])
+        ),
+        h("div", { style: { marginTop: 8 } }, [h(Button, { onClick: save }, "保存设备")]),
+      ]),
+    ];
+  },
+};
+
+export { DeviceImageField, DeviceTemplateCard, CP_RANGE_PRESETS, fileToBase64 };
diff --git a/src-vue/components/DomainPageThumb.vue b/src-vue/components/DomainPageThumb.vue
new file mode 100644 (file)
index 0000000..1123b4d
--- /dev/null
@@ -0,0 +1,51 @@
+<script setup>
+import { ref, watch, onMounted } from "vue";
+import { loadRenderer } from "../lib/api.js";
+
+const props = defineProps({
+  jsonText: { type: String, default: "" },
+  emptyLabel: { type: String, default: "未生成" },
+  fit: { type: Boolean, default: false },
+  fixedHeight: { type: Number, default: undefined },
+});
+
+const outer = ref(null);
+const inner = ref(null);
+
+async function render() {
+  const o = outer.value;
+  const i = inner.value;
+  if (!o || !i) return;
+  if (!props.jsonText || props.jsonText.trim() === "{}" || props.jsonText.trim() === "") {
+    i.innerHTML = '<div class="thumb-empty">' + props.emptyLabel + "</div>";
+    o.style.height = props.fixedHeight ? `${props.fixedHeight}px` : "auto";
+    return;
+  }
+  try {
+    const json = JSON.parse(props.jsonText);
+    const renderDashboard = await loadRenderer();
+    renderDashboard(json, i, {});
+    const w = o.clientWidth || 300;
+    const pw = json.grid?.pageWidth || 1920;
+    const rootEl = i.firstElementChild;
+    const rh = rootEl ? rootEl.offsetHeight : json.grid?.pageHeight || 1080;
+    const availH = props.fixedHeight || (props.fit ? o.parentElement?.clientHeight || rh : Infinity);
+    const s = props.fit ? Math.max(0.02, Math.min(w / pw, availH / rh)) : Math.max(0.02, w / pw);
+    i.style.height = `${rh}px`;
+    i.style.transform = `scale(${s})`;
+    i.style.transformOrigin = "top left";
+    o.style.height = props.fixedHeight ? `${props.fixedHeight}px` : `${rh * s}px`;
+  } catch (e) {
+    i.innerHTML = '<div class="thumb-empty">预览不可用</div>';
+  }
+}
+
+onMounted(render);
+watch(() => [props.jsonText, props.emptyLabel, props.fit, props.fixedHeight], render);
+</script>
+
+<template>
+  <div ref="outer" class="dashboard-thumb-live" style="overflow: hidden">
+    <div ref="inner" style="width: 1920px; height: 1080px; position: relative"></div>
+  </div>
+</template>
diff --git a/src-vue/components/DropdownButton.vue b/src-vue/components/DropdownButton.vue
new file mode 100644 (file)
index 0000000..ec4964f
--- /dev/null
@@ -0,0 +1,36 @@
+<script setup>
+import SvgIcon from "./SvgIcon.vue";
+
+defineProps({
+  label: { type: String, required: true },
+  btnIcon: { type: String, default: "" },
+  value: { type: [String, Number], default: "" },
+  trigger: { type: Array, default: () => ["click"] },
+  placement: { type: String, default: "bottom" },
+  disabled: { type: Boolean, default: false },
+  visible: { type: Boolean, default: undefined },
+});
+const emit = defineEmits(["openChange"]);
+</script>
+
+<template>
+  <button type="button" class="dropdown-btn">
+    <a-dropdown
+      :disabled="disabled"
+      :trigger="trigger"
+      :placement="placement"
+      :open="visible"
+      :class="'self-dropdown'"
+      @open-change="(open) => emit('openChange', open)"
+    >
+      <div class="btn-content">
+        <SvgIcon v-if="btnIcon" :name="btnIcon" :size="24" class="btn-icon" />
+        <span v-if="value" class="filter-text">{{ value }}</span>
+        <span class="text">{{ label }}</span>
+      </div>
+      <template #overlay>
+        <slot />
+      </template>
+    </a-dropdown>
+  </button>
+</template>
diff --git a/src-vue/components/Field.vue b/src-vue/components/Field.vue
new file mode 100644 (file)
index 0000000..a9f5da3
--- /dev/null
@@ -0,0 +1,14 @@
+<script setup>
+import { Form } from "ant-design-vue";
+const props = defineProps({
+  label: { type: String, default: "" },
+  required: { type: Boolean, default: false },
+  hint: { type: String, default: "" },
+});
+</script>
+
+<template>
+  <Form.Item class="field" :label="label" :required="required" :extra="hint" layout="vertical">
+    <slot />
+  </Form.Item>
+</template>
diff --git a/src-vue/components/ImagePage.vue b/src-vue/components/ImagePage.vue
new file mode 100644 (file)
index 0000000..d8ba186
--- /dev/null
@@ -0,0 +1,9 @@
+<script setup>
+const props = defineProps({ src: { type: String, required: true } });
+</script>
+
+<template>
+  <div class="content dashboard-page">
+    <img :src="src" alt="" loading="lazy" />
+  </div>
+</template>
diff --git a/src-vue/components/Login.vue b/src-vue/components/Login.vue
new file mode 100644 (file)
index 0000000..eb4a4f2
--- /dev/null
@@ -0,0 +1,29 @@
+<script setup>
+import UiButton from "../ui/UiButton.vue";
+
+const props = defineProps({
+  onLogin: { type: Function, required: true },
+});
+</script>
+
+<template>
+  <div class="login-page">
+    <div class="login-top">
+      <div class="brand-mark">⬡</div>
+      <b>iStation</b>
+      <span>StationBuilder</span>
+    </div>
+    <div class="login-card">
+      <div class="login-logo">⬡</div>
+      <h1>iStation StationBuilder</h1>
+      <p>面向 iStation 团队的 AI 站点与领域方案生成工具</p>
+      <UiButton className="sso-button" @click="onLogin">
+        <span class="avatar">W</span>
+        <b>使用公司账号登录</b>
+        <i>→</i>
+      </UiButton>
+      <small>仅限 iStation 团队内部成员访问</small>
+    </div>
+    <footer>© 2026 Advantech · iStation Team · v0.3</footer>
+  </div>
+</template>
diff --git a/src-vue/components/Modal.vue b/src-vue/components/Modal.vue
new file mode 100644 (file)
index 0000000..2fa371a
--- /dev/null
@@ -0,0 +1,125 @@
+<script setup>
+import { ref, computed, watch, nextTick } from "vue";
+import UiOverlay from "../ui/UiOverlay.vue";
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import Field from "./Field.vue";
+import PrimaryButton from "./PrimaryButton.vue";
+import StatusTag from "./StatusTag.vue";
+
+const props = defineProps({
+  type: { type: String, required: true },
+  close: { type: Function, required: true },
+  notify: { type: Function, default: () => {} },
+  promptData: { type: Object, default: null },
+  feedbackCube: { type: Object, default: null },
+  refineCube: { type: Function, default: null },
+  refining: { type: Boolean, default: false },
+  refineLog: { type: String, default: "" },
+});
+
+const feedbackText = ref("");
+const refineConsoleRef = ref(null);
+
+watch(
+  () => props.refineLog,
+  async () => {
+    await nextTick();
+    if (refineConsoleRef.value) refineConsoleRef.value.scrollTop = refineConsoleRef.value.scrollHeight;
+  }
+);
+
+const title = computed(
+  () =>
+    ({
+      feedback: `提出修改意见 · ${props.feedbackCube?.name || ""}`,
+      download: "下载站点交付物",
+      history: "版本历史",
+      prompt: `生成提示词 · ${props.promptData?.name || ""}`,
+      completePrompt: "AI 智能补全提示词",
+    }[props.type])
+);
+
+const submitFeedback = () => {
+  if (!props.feedbackCube) {
+    props.notify("未选择目标 Cube 页面,请关闭后重试");
+    props.close();
+    return;
+  }
+  if (!props.refineCube) {
+    props.notify("反馈功能未正确初始化");
+    return;
+  }
+  props.refineCube(props.feedbackCube, feedbackText.value);
+};
+</script>
+
+<template>
+  <UiOverlay className="modal-backdrop" :onMouseDown="close">
+    <div class="modal" @mousedown.stop>
+      <header>
+        <h2>{{ title }}</h2>
+        <UiButton @click="close">×</UiButton>
+      </header>
+      <div class="modal-body">
+        <template v-if="type === 'feedback'">
+          <div class="impact-title">系统已识别本次修改范围(仅针对「{{ feedbackCube?.name }}」页面)</div>
+          <Field label="修改意见">
+            <UiInput
+              autoFocus
+              :value="feedbackText"
+              @change="(e) => (feedbackText = e.target.value)"
+              placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"
+            />
+          </Field>
+          <p class="hint">在上方输入框填写修改意见,提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p>
+          <ul class="impact-list">
+            <li><b>当前页面 JSON</b><span>将依据修改意见重新生成</span></li>
+            <li><b>页面模拟数据</b><span>将同步更新</span></li>
+            <li><b>Asset Excel</b><span>不受影响</span></li>
+            <li><b>其他 Cube 页面</b><span>不受影响</span></li>
+          </ul>
+          <div v-if="refining || refineLog" class="modal-console">
+            <div class="modal-console-head">AI 生成工作流</div>
+            <pre class="modal-console-body" ref="refineConsoleRef">{{ refineLog || "(等待 AI 反馈…)" }}</pre>
+          </div>
+        </template>
+
+        <template v-else-if="type === 'download'">
+          <div class="download-list">
+            <label v-for="x in ['Cube 页面 JSON', '站点菜单配置', '生成及校验报告']" :key="x">
+              <UiInput type="checkbox" defaultChecked /><span>{{ x }}</span>
+            </label>
+            <div><b>station-package.zip</b><span>包含全部生成产物</span></div>
+          </div>
+        </template>
+
+        <template v-else-if="type === 'history'">
+          <div class="history-list">
+            <div v-for="x in [['V1', '首次生成', new Date().toLocaleString()]]" :key="x[0]">
+              <i>{{ x[0] }}</i>
+              <span><b>{{ x[1] }}</b><small>{{ x[2] }} · 王宇</small></span>
+              <StatusTag>当前版本</StatusTag>
+            </div>
+          </div>
+        </template>
+
+        <template v-else-if="type === 'prompt'">
+          <p class="hint">这是发送给 Cube 生成模型的完整组合提示词。在点击「开始生成」前可在此核对站点上下文、页面要求与 Cube 规则是否准确,便于定位生成质量问题。</p>
+          <pre class="prompt-view">{{ promptData?.text || "(暂无提示词)" }}</pre>
+        </template>
+
+        <template v-else-if="type === 'completePrompt'">
+          <p class="hint">这是「AI 智能补全」发送给分析模型的完整提示词。它基于站点名称、所属领域与站点基本情况描述,要求模型先输出分析思路,再以「【补充描述】」给出最终要写入「补充要求」的文本。</p>
+          <pre class="prompt-view">{{ promptData?.text || "(暂无提示词)" }}</pre>
+        </template>
+      </div>
+      <footer>
+        <PrimaryButton secondary @click="close">关闭</PrimaryButton>
+        <PrimaryButton v-if="type === 'feedback'" :disabled="refining" @click="submitFeedback">
+          {{ refining ? "重新生成中…" : "提交修改意见" }}
+        </PrimaryButton>
+      </footer>
+    </div>
+  </UiOverlay>
+</template>
diff --git a/src-vue/components/Pagination.vue b/src-vue/components/Pagination.vue
new file mode 100644 (file)
index 0000000..a144b56
--- /dev/null
@@ -0,0 +1,52 @@
+<script setup>
+import { computed } from "vue";
+import SvgIcon from "./SvgIcon.vue";
+
+const props = defineProps({
+  current: { type: Number, required: true },
+  totalPages: { type: Number, required: true },
+  pageSize: { type: Number, required: true },
+});
+const emit = defineEmits(["pageChange", "pageSizeChange"]);
+
+const atFirst = computed(() => props.current <= 1);
+const atLast = computed(() => props.current >= props.totalPages);
+</script>
+
+<template>
+  <footer class="page-footer">
+    <div class="pagination">
+      <SvgIcon
+        name="First"
+        :size="24"
+        :class="['page-icon', { disabled: atFirst }]"
+        @click="() => !atFirst && emit('pageChange', 1)"
+      />
+      <SvgIcon
+        name="Previous"
+        :size="24"
+        :class="['page-icon', { disabled: atFirst }]"
+        @click="() => !atFirst && emit('pageChange', current - 1)"
+      />
+      <span class="page-indicator">{{ current }} / {{ totalPages }}</span>
+      <SvgIcon
+        name="Next"
+        :size="24"
+        :class="['page-icon', { disabled: atLast }]"
+        @click="() => !atLast && emit('pageChange', current + 1)"
+      />
+      <SvgIcon
+        name="Last"
+        :size="24"
+        :class="['page-icon', { disabled: atLast }]"
+        @click="() => !atLast && emit('pageChange', totalPages)"
+      />
+      <a-select
+        class="page-size"
+        :value="pageSize"
+        :options="[10, 20, 50, 100].map((v) => ({ label: v + ' / page', value: v }))"
+        @change="(v) => emit('pageSizeChange', v)"
+      />
+    </div>
+  </footer>
+</template>
diff --git a/src-vue/components/Panel.vue b/src-vue/components/Panel.vue
new file mode 100644 (file)
index 0000000..958e894
--- /dev/null
@@ -0,0 +1,27 @@
+<script setup>
+import { computed, h } from "vue";
+import { Card } from "ant-design-vue";
+import UiActions from "../ui/UiActions.vue";
+
+const props = defineProps({
+  title: { type: String, default: "" },
+  className: { type: String, default: "" },
+});
+const slots = defineSlots();
+
+const titleVNode = computed(() => (props.title ? h("strong", props.title) : undefined));
+</script>
+
+<template>
+  <Card
+    class="panel"
+    :class="className"
+    :title="titleVNode"
+    :class-names="{ header: 'panel-head', body: 'panel-body' }"
+  >
+    <template v-if="title && slots.tools" #extra>
+      <UiActions class="station-inline-actions"><slot name="tools" /></UiActions>
+    </template>
+    <slot />
+  </Card>
+</template>
diff --git a/src-vue/components/PrimaryButton.vue b/src-vue/components/PrimaryButton.vue
new file mode 100644 (file)
index 0000000..0f54b97
--- /dev/null
@@ -0,0 +1,24 @@
+<script setup>
+import { Button as AntButton } from "ant-design-vue";
+
+// Port of React local `Button`: primary by default, secondary => default.
+const props = defineProps({
+  secondary: { type: Boolean, default: false },
+  onClick: { type: Function, default: null },
+  disabled: { type: Boolean, default: false },
+  title: { type: String, default: undefined },
+});
+</script>
+
+<template>
+  <AntButton
+    :title="title"
+    :disabled="disabled"
+    :onClick="onClick"
+    :type="secondary ? 'default' : 'primary'"
+    class="button"
+    :class="secondary ? 'secondary' : ''"
+  >
+    <slot />
+  </AntButton>
+</template>
diff --git a/src-vue/components/ResourcePlaceholder.vue b/src-vue/components/ResourcePlaceholder.vue
new file mode 100644 (file)
index 0000000..f933177
--- /dev/null
@@ -0,0 +1,11 @@
+<script setup>
+import Panel from "./Panel.vue";
+
+const props = defineProps({ title: { type: String, required: true } });
+</script>
+
+<template>
+  <Panel :title="title">
+    <div class="empty-state">该模块正在建设中,敬请期待。</div>
+  </Panel>
+</template>
diff --git a/src-vue/components/SearchInput.vue b/src-vue/components/SearchInput.vue
new file mode 100644 (file)
index 0000000..bc60a6d
--- /dev/null
@@ -0,0 +1,27 @@
+<script setup>
+import SvgIcon from "./SvgIcon.vue";
+
+const props = defineProps({
+  value: { type: String, default: "" },
+  placeholder: { type: String, default: "" },
+  className: { type: String, default: "" },
+});
+const emit = defineEmits(["change"]);
+</script>
+
+<template>
+  <a-input
+    class="search-input"
+    :class="className"
+    :value="value"
+    :placeholder="placeholder"
+    allow-clear
+    @change="(e) => emit('change', e.target.value)"
+  >
+    <template #prefix>
+      <span class="search-prefix">
+        <SvgIcon name="search" :size="24" />
+      </span>
+    </template>
+  </a-input>
+</template>
diff --git a/src-vue/components/SideMenu.vue b/src-vue/components/SideMenu.vue
new file mode 100644 (file)
index 0000000..8e38284
--- /dev/null
@@ -0,0 +1,30 @@
+<script setup>
+import { h } from "vue";
+import SvgIcon from "./SvgIcon.vue";
+
+defineProps({
+  items: { type: Array, default: () => [] },
+  collapsed: { type: Boolean, default: false },
+  selectedKeys: { type: Array, default: () => [] },
+  openKeys: { type: Array, default: null },
+});
+
+const emit = defineEmits(["click", "openChange"]);
+
+const expandIcon = ({ isOpen }) =>
+  h(SvgIcon, { name: isOpen ? "arrow-down" : "arrow-right", size: 24 });
+</script>
+
+<template>
+  <a-menu
+    mode="inline"
+    theme="light"
+    :inline-collapsed="collapsed"
+    :selected-keys="selectedKeys"
+    :open-keys="openKeys"
+    :items="items"
+    :expand-icon="expandIcon"
+    @click="(info) => emit('click', info)"
+    @open-change="(keys) => emit('openChange', keys)"
+  />
+</template>
diff --git a/src-vue/components/SiteEditTabs.vue b/src-vue/components/SiteEditTabs.vue
new file mode 100644 (file)
index 0000000..2407915
--- /dev/null
@@ -0,0 +1,24 @@
+<script setup>
+import UiButton from "../ui/UiButton.vue";
+import { SITE_EDIT_STEPS, meta } from "../lib/meta.js";
+
+const props = defineProps({
+  section: { type: String, required: true },
+  onChange: { type: Function, required: true },
+});
+</script>
+
+<template>
+  <nav class="site-edit-tabs" aria-label="站点编辑功能">
+    <UiButton
+      v-for="(item, index) in SITE_EDIT_STEPS"
+      :key="item"
+      :class="section === item ? 'active' : ''"
+      :aria-current="section === item ? 'page' : undefined"
+      @click="onChange(item)"
+    >
+      <i>{{ index + 1 }}</i>
+      <span>{{ meta[item].label }}</span>
+    </UiButton>
+  </nav>
+</template>
diff --git a/src-vue/components/StatusTag.vue b/src-vue/components/StatusTag.vue
new file mode 100644 (file)
index 0000000..247c30f
--- /dev/null
@@ -0,0 +1,17 @@
+<script setup>
+import { computed } from "vue";
+import { Tag } from "ant-design-vue";
+
+const props = defineProps({ value: { type: String, default: "" } });
+const tone = computed(() => {
+  const v = props.value;
+  if (/不可用|失败|异常|failed/.test(v)) return "bad";
+  if (/完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(v)) return "ok";
+  if (/生成中|校验中|告警|running/.test(v)) return "warn";
+  return "muted";
+});
+</script>
+
+<template>
+  <Tag class="status" :class="tone"><slot>{{ value }}</slot></Tag>
+</template>
diff --git a/src-vue/components/Steps.vue b/src-vue/components/Steps.vue
new file mode 100644 (file)
index 0000000..0e0f78c
--- /dev/null
@@ -0,0 +1,24 @@
+<script setup>
+import UiButton from "../ui/UiButton.vue";
+import { SITE_EDIT_STEPS, meta } from "../lib/meta.js";
+
+const props = defineProps({
+  page: { type: String, required: true },
+  go: { type: Function, required: true },
+});
+</script>
+
+<template>
+  <div class="steps">
+    <UiButton
+      v-for="(item, index) in SITE_EDIT_STEPS"
+      :key="item"
+      :class="index === SITE_EDIT_STEPS.indexOf(page) ? 'active' : index < SITE_EDIT_STEPS.indexOf(page) ? 'done' : ''"
+      @click="index <= SITE_EDIT_STEPS.indexOf(page) && go(item)"
+    >
+      <i>{{ index < SITE_EDIT_STEPS.indexOf(page) ? "✓" : index + 1 }}</i>
+      <span><small>STEP {{ index + 1 }}</small>{{ meta[item].label }}</span>
+      <b v-if="index < SITE_EDIT_STEPS.length - 1">›</b>
+    </UiButton>
+  </div>
+</template>
diff --git a/src-vue/components/SvgIcon.vue b/src-vue/components/SvgIcon.vue
new file mode 100644 (file)
index 0000000..3f36d12
--- /dev/null
@@ -0,0 +1,46 @@
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  name: { type: String, required: true },
+  size: { type: Number, default: 24 },
+  color: { type: String, default: "" },
+  onClick: { type: Function, default: null },
+});
+
+// All icons live in src/assets/icons/*.svg and use fill="currentColor".
+const modules = import.meta.glob("../../src/assets/icons/*.svg", {
+  query: "?raw",
+  import: "default",
+  eager: true,
+});
+
+const ICONS = {};
+for (const path in modules) {
+  const key = path.split("/").pop().replace(".svg", "");
+  ICONS[key] = modules[path];
+}
+
+const raw = computed(() => ICONS[props.name] || "");
+</script>
+
+<template>
+  <i
+    class="sb-svg-icon"
+    :style="{ width: size + 'px', height: size + 'px', color }"
+    :onClick="onClick"
+    v-html="raw"
+  />
+</template>
+
+<style scoped>
+.sb-svg-icon {
+  display: inline-flex;
+  flex: none;
+}
+.sb-svg-icon :deep(svg) {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+</style>
diff --git a/src-vue/components/dropdown-button.less b/src-vue/components/dropdown-button.less
new file mode 100644 (file)
index 0000000..af58f02
--- /dev/null
@@ -0,0 +1,133 @@
+//带有菜单的按钮
+.dropdown-btn {
+    height: 34px;
+    padding: 0;
+    border: 1px solid #C8C8C8;
+    background: #FFFFFF;
+    box-shadow: none;
+    border-radius: 4px;
+    cursor: pointer;
+    font-family: 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
+        'Helvetica Neue', sans-serif;
+
+    &:hover,
+    &:focus {
+        border-color: #008CD6;
+    }
+
+    &:focus {
+        outline: none;
+        box-shadow: 0px 2px 8px 0px rgba(0, 140, 214, 0.5);
+    }
+
+    .btn-content {
+        height: 100%;
+        padding: 0 8px;
+        display: flex;
+        flex-wrap: nowrap;
+        justify-content: left;
+    }
+
+    &.blue {
+        color: #008CD6;
+    }
+
+    &.blue:not(.disabled):not([disabled]) .btn-content {
+        &:hover {
+            color: #006AA5;
+            border-color: #89898A;
+        }
+
+        &:active,
+        &.ant-dropdown-open,
+        &.ant-dropdown-open:hover {
+            color: #008CD6;
+            background: linear-gradient(315deg, rgba(11, 76, 217, 0.1) 0%, rgba(0, 140, 214, 0.1) 100%), #FFFFFF;
+            border-color: #008CD6;
+        }
+    }
+
+    &.filter {
+        color: #323233;
+
+        .btn-content {
+            flex-direction: row-reverse;
+            padding: 0 8px;
+            height: 100%;
+            display: flex;
+            align-items: center;
+            gap: 4px;
+            font-size: 16px;
+            line-height: 24px;
+
+            .filter-text {
+                font-weight: 400;
+                height: 24px;
+                line-height: 24px;
+                color: #323233;
+                display: inline-block;
+                max-width: 212px;
+                overflow: hidden;
+                white-space: nowrap;
+                text-overflow: ellipsis;
+            }
+
+            .text {
+                color: #89898A;
+                font-weight: 700;
+            }
+
+            // 图标颜色由 currentColor 驱动
+            .btn-icon {
+                display: block;
+                color: #89898A;
+                transition: color .3s ease-in;
+            }
+
+            &.ant-dropdown-open {
+                .btn-icon {
+                    color: var(--ant-color-primary);
+                }
+            }
+
+            &:active:not([disabled]),
+            &.ant-dropdown-open,
+            &.ant-dropdown-open:hover {
+                background: transparent !important;
+
+                .text {
+                    color: #008CD6 !important;
+                }
+            }
+        }
+    }
+}
+
+// 下拉菜单(挂载到 body)
+.self-dropdown.ant-dropdown {
+    .ant-dropdown-menu {
+        max-height: 300px;
+        overflow-y: auto;
+        padding: 8px 0;
+        border: 1px solid #008CD6;
+        border-radius: 4px;
+        box-shadow: 0 2px 8px 0 rgba(50, 50, 51, 0.2);
+
+        .ant-dropdown-menu-item {
+            font-size: 1rem;
+            line-height: 24px;
+            padding: 8px 12px;
+
+            &.selected-menu,
+            &:hover,
+            &.selected,
+            &.selected:hover {
+                background: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%);
+            }
+
+            .blue {
+                color: #008CD6;
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src-vue/components/header.less b/src-vue/components/header.less
new file mode 100644 (file)
index 0000000..5d389ff
--- /dev/null
@@ -0,0 +1,242 @@
+@g-brand-hover: linear-gradient(180deg, rgba(0, 140, 214, 0.05) 0%, rgba(0, 110, 255, 0.05) 100%), #FFF;
+@g-brand-active: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%), #FFF;
+
+.header {
+    background-color: var(--ant-color-bg-container);
+    box-shadow: 0px 2px 8px rgba(50, 50, 51, 0.20);
+    position: fixed;
+    width: 100%;
+    top: 0;
+    z-index: 100;
+    height: 56px;
+
+    .top-bar {
+        height: 8px;
+        // 顶部品牌渐变条(token 表达不了)
+        background: linear-gradient(90deg, #008cd6 0%, #004280 100%);
+    }
+
+    .header-info {
+        padding: 0 24px;
+
+        // 折叠开关图标:常态 / hover / active 由 currentColor 驱动
+        .menu-toggle {
+            color: #89898A;
+            cursor: pointer;
+            transition: color .3s ease-in;
+
+            &:hover {
+                color: var(--ant-color-text-base);
+            }
+
+            &:active {
+                color: var(--ant-color-primary);
+            }
+        }
+
+        .portal-logo {
+            height: 40px;
+            min-width: auto;
+            margin-right: 12px
+        }
+
+        .portal-name {
+            color: var(--ant-color-text-base);
+            font-size: 20px;
+            font-weight: 700;
+            line-height: 32px;
+        }
+
+        .drop-menu {
+            cursor: pointer;
+            height: 48px;
+            display: flex;
+            align-items: center;
+            transition: .4s ease-in;
+
+            &:hover {
+                background: @g-brand-hover;
+
+                .nav-icon.drop {
+                    color: var(--ant-color-text-base);
+                }
+            }
+
+            .ant-dropdown-open {
+                height: 100%;
+                display: flex;
+                align-items: center;
+                background: @g-brand-active;
+
+                &::after {
+                    content: '';
+                    height: 3px;
+                    width: 100%;
+                    background: var(--ant-color-primary);
+                    position: absolute;
+                    bottom: 0;
+                }
+            }
+
+            // 图标颜色由 SvgIcon 的 currentColor 驱动
+            .file-icon {
+                cursor: pointer;
+                color: #89898A;
+                transition: color .3s ease-in;
+
+                &.active {
+                    color: var(--ant-color-primary);
+                }
+            }
+
+            .nav-icon {
+                cursor: pointer;
+                transition: color .3s ease-in;
+
+                &.drop {
+                    color: #89898A;
+
+                    &.active {
+                        color: var(--ant-color-primary);
+                    }
+                }
+            }
+        }
+
+        .right-content {
+            padding: 8px 0;
+
+            // 右侧图标:常态 / hover / active 由 currentColor 驱动
+            .switch-manage,
+            .right-icon {
+                svg {
+                    color: #89898A;
+                    transition: color .3s ease-in;
+                }
+
+                &:hover svg {
+                    color: var(--ant-color-text-base);
+                }
+
+                &:active svg {
+                    color: var(--ant-color-primary);
+                }
+            }
+
+            .has-user {
+                width: 24px;
+                height: 24px;
+                line-height: 24px;
+                font-style: normal;
+                font-weight: 400;
+                font-size: 12px;
+                text-align: center;
+                color: var(--ant-color-primary);
+                // 品牌浅蓝,无对应 token
+                background: #B4E3F6;
+                mix-blend-mode: normal;
+                border-radius: 50%;
+            }
+
+            .nav-icon {
+                display: flex;
+                width: 24px;
+                height: 24px;
+            }
+        }
+    }
+
+    @media (max-width: 767px) {
+        .header-info {
+            padding: 0 12px;
+
+            .portal-name {
+                font-size: 16px;
+                line-height: 24px;
+            }
+        }
+    }
+
+    @media (max-width: 539px) {
+        .header-info {
+            .portal-logo {
+                display: none;
+            }
+        }
+    }
+}
+
+// Header 内部工具类:嵌套在 .header 内,作用域不外泄,
+// 避免与页面(含 LLM 生成的)类名冲突 —— 页面里能用的类必须都是显式定义的。
+.header {
+    .flex {
+        display: flex;
+    }
+
+    .items-center {
+        align-items: center;
+    }
+
+    .justify-center {
+        justify-content: center;
+    }
+
+    .justify-between {
+        justify-content: space-between;
+    }
+
+    .cursor-pointer {
+        cursor: pointer;
+    }
+
+    .gray {
+        color: #89898A;
+    }
+
+    .black {
+        color: var(--ant-color-text-base);
+    }
+
+    .blue {
+        color: var(--ant-color-primary);
+    }
+
+    .ml-1 {
+        margin-left: 4px;
+    }
+
+    .ml-2 {
+        margin-left: 8px;
+    }
+
+    .mr-4 {
+        margin-right: 16px;
+    }
+
+    .mr-12 {
+        margin-right: 12px;
+    }
+
+    .px-4 {
+        padding-left: 4px;
+        padding-right: 4px;
+    }
+
+    .text-small {
+        font-size: 12px;
+        line-height: 16px;
+    }
+
+    .text-normal {
+        font-size: 16px;
+        line-height: 24px;
+    }
+
+    .bold {
+        font-weight: 700;
+    }
+
+    .font-w-4 {
+        font-weight: 400;
+    }
+}
diff --git a/src-vue/components/menu.less b/src-vue/components/menu.less
new file mode 100644 (file)
index 0000000..3da7fc9
--- /dev/null
@@ -0,0 +1,201 @@
+// SideMenu 私有样式。选择器为全局 .ant-menu:折叠态子菜单由 antd 渲染到 body 下的 portal,
+// 限定作用域会漏掉 popup(等效 L0 第 5 层兜底)。
+@g-brand-active: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%), #FFF;
+
+.ant-menu {
+  width: 316px;
+  height: 100%;
+  background: #E1E1E1;
+
+  /* 折叠态弹出子菜单 */
+  &.ant-menu-submenu-popup {
+    width: unset;
+    height: unset;
+    padding-left: 0;
+    border: 1px solid var(--ant-color-primary);
+    box-shadow: 0px 2px 8px 0px rgba(0, 140, 214, 0.5);
+
+    .ant-menu {
+      width: unset;
+      height: unset;
+
+      .ant-menu-item {
+        width: unset;
+        margin-inline: 0;
+
+        &:hover {
+          background: @g-brand-active !important;
+        }
+
+        &.ant-menu-item-selected {
+          &::before {
+            display: none;
+          }
+        }
+      }
+
+      .ant-menu-submenu {
+        &:hover {
+          background: @g-brand-active !important;
+        }
+
+        .ant-menu-submenu-title {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          padding-inline: 16px;
+        }
+      }
+    }
+  }
+
+  .ant-menu-submenu-expand-icon {
+    width: auto;
+  }
+
+  .ant-menu-item {
+    margin: 0;
+    margin-inline: 0;
+    width: 100%;
+
+    /* 渐变背景:antd 用 background-color 无法设渐变,必须直接覆盖 background */
+    &:hover {
+      background: @g-brand-active !important;
+    }
+
+    &.ant-menu-item-selected {
+      background: @g-brand-active;
+
+      &::before {
+        content: "";
+        display: block;
+        position: absolute;
+        left: 0;
+        width: 4px;
+        height: 24px;
+        background-color: var(--ant-color-primary);
+      }
+    }
+  }
+
+  .ant-menu-submenu {
+    .ant-menu-submenu-title {
+      margin: 0;
+      width: 100%;
+      &:hover {
+        background: @g-brand-active !important;
+      }
+    }
+
+    &.ant-menu-submenu-selected {
+      background: #FAFAFA;
+
+      .ant-menu-sub {
+        background: #FAFAFA;
+      }
+    }
+
+    .ant-menu-sub {
+      position: relative;
+
+      &::before {
+        content: '';
+        width: 1px;
+        height: 100%;
+        background: #C8C8C8;
+        position: absolute;
+        left: 33px;
+        top: 0;
+        z-index: 2;
+      }
+
+      .ant-menu-submenu {
+        svg {
+          position: relative;
+          left: -3px;
+        }
+
+        .ant-menu-submenu-title {
+          margin: 0 0 0 3px;
+        }
+
+        .ant-menu-sub {
+          .ant-menu-item {
+            .ant-menu-title-content {
+              &::before {
+                content: '';
+                width: 1px;
+                height: 100%;
+                background: #C8C8C8;
+                position: absolute;
+                left: 53px;
+                top: 0;
+              }
+            }
+          }
+        }
+      }
+
+      .ant-menu-item {
+        margin-left: 3px;
+
+        &.ant-menu-item-selected {
+          &::before {
+            content: "";
+            display: block;
+            position: relative;
+            left: -18px;
+            top: 0;
+            width: 4px;
+            height: 24px;
+            background-color: var(--ant-color-primary);
+          }
+
+          .ant-menu-title-content {
+            margin-left: -4px;
+          }
+        }
+      }
+    }
+  }
+
+  &.ant-menu-inline-collapsed {
+    width: 72px;
+    background: #E1E1E1;
+    border-right: 1px solid #C8C8C8;
+
+    .ant-menu-item {
+      margin-top: 4px;
+
+      &:hover {
+        background: @g-brand-active !important;
+      }
+
+      &::before {
+        top: 8px;
+      }
+    }
+
+    .ant-menu-submenu {
+      .ant-menu-submenu-title:hover {
+        background: @g-brand-active;
+      }
+
+      &.ant-menu-submenu-selected {
+        position: relative;
+        background: @g-brand-active;
+
+        &::before {
+          content: "";
+          display: block;
+          position: absolute;
+          left: 0;
+          top: 8px;
+          width: 4px;
+          height: 24px;
+          background-color: var(--ant-color-primary);
+        }
+      }
+    }
+  }
+}
diff --git a/src-vue/components/pagination.less b/src-vue/components/pagination.less
new file mode 100644 (file)
index 0000000..11ae9e2
--- /dev/null
@@ -0,0 +1,101 @@
+// 底部分页栏
+@g-brand-hover: linear-gradient(180deg, rgba(0, 140, 214, 0.05) 0%, rgba(0, 110, 255, 0.05) 100%), #FFF;
+@g-brand-active: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%), #FFF;
+
+.page-footer {
+    background: var(--ant-color-bg-layout);
+    border-top: 1px solid #E1E1E1;
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    padding: 8px 24px;
+
+    .pagination {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+
+        .page-indicator {
+            margin: 0 8px;
+            color: var(--ant-color-text-base);
+        }
+
+        // 翻页箭头:常态 / hover / active / disabled 由 currentColor 驱动
+        .page-icon {
+            color: #89898A;
+            cursor: pointer;
+            transition: color .3s ease-in;
+
+            &:hover {
+                color: var(--ant-color-text-base);
+            }
+
+            &:active {
+                color: var(--ant-color-primary);
+            }
+
+            &.disabled {
+                color: #C0C0C0;
+                cursor: not-allowed;
+                pointer-events: none;
+            }
+        }
+
+        .page-size {
+            margin-left: 24px;
+        }
+    }
+
+    // Select(pageSize 切换)
+    .ant-select {
+        border-radius: var(--ant-border-radius);
+
+        .ant-select-selector {
+            background: var(--ant-color-bg-layout);
+            border: none !important;
+            font-size: 16px;
+            line-height: 24px;
+            padding: 4px 8px;
+
+            .ant-select-selection-item {
+                color: var(--ant-color-text-base);
+            }
+        }
+
+        // 箭头图标通过 suffixIcon 传入,颜色由 currentColor 驱动
+        .ant-select-arrow {
+            margin-left: 4px;
+            top: 40%;
+            right: 0;
+            width: 24px;
+            height: 24px;
+            color: #89898A;
+            transition: color .3s ease-in;
+        }
+
+        &:hover {
+            .ant-select-selector {
+                background: @g-brand-hover;
+            }
+
+            .ant-select-arrow {
+                color: var(--ant-color-text-base);
+            }
+        }
+
+        &.ant-select-focused {
+            .ant-select-selector {
+                box-shadow: none;
+                background: @g-brand-active;
+
+                .ant-select-selection-item {
+                    color: var(--ant-color-primary);
+                }
+            }
+
+            .ant-select-arrow {
+                color: var(--ant-color-primary);
+            }
+        }
+    }
+}
diff --git a/src-vue/components/search-input.less b/src-vue/components/search-input.less
new file mode 100644 (file)
index 0000000..ec168b6
--- /dev/null
@@ -0,0 +1,36 @@
+// Input(作用域限定到本组件,避免影响页面上其它带 affix 的 Input)
+.search-input.ant-input-affix-wrapper {
+  width: 240px;
+  padding: 4px 8px;
+  font-size: 1rem;
+  line-height: 24px;
+  border-color: #C8C8C8;
+
+  &:hover {
+    border-color: #008CD6;
+  }
+
+  .ant-input {
+    font-size: 1rem;
+    line-height: 24px;
+  }
+
+  // 搜索图标:颜色由 currentColor 驱动
+  .search-prefix svg {
+    color: #89898A;
+    transition: color .3s ease-in;
+
+    &:hover {
+      color: var(--ant-color-text-base);
+    }
+
+    &:active {
+      color: var(--ant-color-primary);
+    }
+  }
+
+  &.ant-input-affix-wrapper-focused {
+    border-color: #008CD6;
+    box-shadow: 0px 2px 8px 0px rgba(0, 140, 214, 0.5);
+  }
+}
\ No newline at end of file
diff --git a/src-vue/index.html b/src-vue/index.html
new file mode 100644 (file)
index 0000000..f8656ce
--- /dev/null
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>StationBuilder 建站工具</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="./main.js"></script>
+  </body>
+</html>
diff --git a/src-vue/lib/api.js b/src-vue/lib/api.js
new file mode 100644 (file)
index 0000000..710c03a
--- /dev/null
@@ -0,0 +1,90 @@
+// Shared helpers ported from app/page.tsx (data layer + small utilities).
+
+export async function fetchDb(path, opts) {
+  const res = await fetch(`/api/db${path}`, opts);
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    throw new Error(text || `HTTP ${res.status}`);
+  }
+  const ct = res.headers.get("content-type") || "";
+  if (ct.includes("application/json")) return res.json();
+  return res;
+}
+
+export function downloadBlob(blob, filename) {
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = filename;
+  a.click();
+  URL.revokeObjectURL(url);
+}
+
+export function formatInstances(rule, count) {
+  if (!rule || count <= 0) return "";
+  const pad = Math.max(2, String(count).length);
+  const parts = [];
+  for (let i = 1; i <= count; i++) {
+    const num = String(i).padStart(pad, "0");
+    if (rule.includes("#")) {
+      parts.push(rule.replace(/#+/g, (m) => String(i).padStart(Math.max(pad, m.length), "0")));
+    } else {
+      const sep = rule.endsWith("-") ? "" : "-";
+      parts.push(`${rule}${sep}${num}`);
+    }
+  }
+  return parts.join("、");
+}
+
+export function buildMuLeaves(nodes, parent = "") {
+  const out = [];
+  for (const n of nodes) {
+    const myParent = parent ? `${parent}/${n.key}` : n.key;
+    if (n.tabs && n.tabs.length) {
+      for (const t of n.tabs) {
+        if (typeof t === "string") out.push({ key: `${myParent}/${t}`, label: t, parentMenu: parent || n.key });
+        else out.push({ key: `${myParent}/${t.label}`, label: t.label, disabled: t.disabled, note: t.note, parentMenu: parent || n.key });
+      }
+    } else if (n.children) {
+      out.push(...buildMuLeaves(n.children, myParent));
+    } else {
+      out.push({ key: myParent, label: n.key, parentMenu: parent || "" });
+    }
+  }
+  return out;
+}
+
+export function countChars(v) {
+  return Array.from(v || "").length;
+}
+
+export function domainTagColor(name) {
+  const map = { 数据中心: "blue", 智慧水务: "cyan", 燃气: "orange", "水务 · 自来水厂": "green", 新能源: "purple", 其他: "default" };
+  if (map[name]) return map[name];
+  const palette = ["blue", "cyan", "orange", "green", "purple", "red"];
+  let hash = 0;
+  for (let i = 0; i < name.length; i++) {
+    hash = (hash << 5) - hash + name.charCodeAt(i);
+    hash |= 0;
+  }
+  return palette[Math.abs(hash) % palette.length];
+}
+
+// Cube renderer loader (used by DashboardThumb / DomainPageThumb).
+let rendererPromise = null;
+export function loadRenderer() {
+  if (rendererPromise) return rendererPromise;
+  rendererPromise = new Promise((resolve, reject) => {
+    const w = window;
+    if (w.StationBuilderCubeRenderer) {
+      resolve(w.StationBuilderCubeRenderer);
+      return;
+    }
+    const s = document.createElement("script");
+    s.src = "/t/stationbuilder/cube-renderer.js";
+    s.onload = () => (w.StationBuilderCubeRenderer ? resolve(w.StationBuilderCubeRenderer) : reject(new Error("renderer missing")));
+    s.onerror = () => reject(new Error("renderer load failed"));
+    document.head.appendChild(s);
+  });
+  return rendererPromise;
+}
diff --git a/src-vue/lib/meta.js b/src-vue/lib/meta.js
new file mode 100644 (file)
index 0000000..7d9c7f8
--- /dev/null
@@ -0,0 +1,54 @@
+// Port of page.tsx meta / SITE_EDIT_STEPS / MODELS constants.
+
+export const PAGE_KEYS = [
+  "login", "projects", "siteEdit", "basic", "assets", "opsflow", "pageConfig",
+  "functions", "generate", "result", "preview", "domains", "domainDetail",
+  "styleLibrary", "deviceTemplates", "toolSettings", "domainSettings", "dashboard",
+  "report-dashboard", "report-doc", "basic-components", "func-components", "filters",
+  "datasource", "monitor-dashboard", "twin-model", "ops-template", "knowledge-template",
+];
+
+export const meta = {
+  login: { label: "内部登录", icon: "◇", subtitle: "使用公司账号登录" },
+  projects: { label: "站点管理", icon: "▦", subtitle: "先建立上下级站点,再按模板配置各站点" },
+  siteEdit: { label: "站点编辑", icon: "", subtitle: "维护已建站点的基本属性与参考资料" },
+  basic: { label: "站点信息", icon: "▤", subtitle: "维护站点基本信息与参考资料" },
+  assets: { label: "资产设置", icon: "▧", subtitle: "在一个工作区完成资产结构、设备节点、属性与数据绑定" },
+  opsflow: { label: "运维流程配置", icon: "", subtitle: "" },
+  pageConfig: { label: "页面配置", icon: "", subtitle: "选择站点所需的标准功能页面、领域仪表板及数字孪生场景" },
+  functions: { label: "菜单配置", icon: "☷", subtitle: "设置应用基本信息,配置第四步已选择页面的菜单" },
+  generate: { label: "生成确认", icon: "↻", subtitle: "确认生成范围,调用 Cube Skill 生成页面和模拟数据" },
+  result: { label: "站点结果", icon: "◫", subtitle: "集中查看、预览、校验并下载站点交付物" },
+  preview: { label: "Cube 页面预览", icon: "▣", subtitle: "检查单个 Cube 页面效果,并通过反馈快速重新生成" },
+  domains: { label: "领域方案", icon: "◆", subtitle: "查看已沉淀的领域能力,并按需预览、下载或导入方案" },
+  domainDetail: { label: "方案详情", icon: "◇", subtitle: "集中查看领域方案包含的资产、页面、知识与菜单能力" },
+  deviceTemplates: { label: "设备模板", icon: "⬡", subtitle: "管理设备模板及采集点配置,供领域方案和站点复用" },
+  styleLibrary: { label: "页面样式", icon: "❖", subtitle: "管理可复用的 Cube 页面视觉样式与预览资源" },
+  toolSettings: { label: "内部工具配置", icon: "", subtitle: "配置 StationBuilder 使用的内部能力、AI 服务和默认模型" },
+  domainSettings: { label: "领域配置", icon: "", subtitle: "维护领域分类,供领域方案与站点项目统一使用" },
+  dashboard: { label: "仪表板", icon: "◈", subtitle: "仅展示仪表盘示意图片" },
+  "report-dashboard": { label: "仪表板报表", icon: "", subtitle: "" },
+  "report-doc": { label: "文档报表", icon: "", subtitle: "" },
+  "basic-components": { label: "基础组件库", icon: "", subtitle: "" },
+  "func-components": { label: "功能组件库", icon: "", subtitle: "" },
+  filters: { label: "筛选器", icon: "", subtitle: "" },
+  datasource: { label: "数据源", icon: "", subtitle: "" },
+  "monitor-dashboard": { label: "监控仪表板", icon: "", subtitle: "" },
+  "twin-model": { label: "数字孪生模型", icon: "", subtitle: "" },
+  "ops-template": { label: "运维模板", icon: "", subtitle: "" },
+  "knowledge-template": { label: "知识模板", icon: "", subtitle: "" },
+};
+
+export const SITE_EDIT_STEPS = ["basic", "assets", "opsflow", "pageConfig", "functions"];
+
+export const MODELS = [
+  { name: "Auto", tag: "自动选择", rate: "—" },
+  { name: "Hy3", tag: "免费", rate: "0.00x" },
+  { name: "GLM-5.4", tag: "订阅优先", rate: "0.79x" },
+  { name: "GLM-5.3", tag: "订阅优先", rate: "0.79x" },
+  { name: "GLM-5.2", tag: "夜间折扣", rate: "0.79x" },
+  { name: "GLM-5.1", tag: "", rate: "0.79x" },
+  { name: "GLM-5v-Turbo", tag: "视觉理解", rate: "0.95x" },
+  { name: "MiniMax-M3", tag: "高性价比", rate: "0.25x" },
+  { name: "Kimi-K3", tag: "订阅优先", rate: "1.62x" },
+];
diff --git a/src-vue/main.js b/src-vue/main.js
new file mode 100644 (file)
index 0000000..7efc450
--- /dev/null
@@ -0,0 +1,24 @@
+import { createApp } from "vue";
+import Antd from "ant-design-vue";
+import "ant-design-vue/dist/reset.css";
+
+// Brand / layout styles — order mirrors the React app's src/main.tsx.
+import "./styles/globals.css";
+import "./styles/site-workspace.css";
+import "./styles/ui-system.css";
+import "./styles/ui-layout-compat.css";
+import "./styles/antd.less";
+import "./styles/theme-overrides.css";
+import "./styles/styles.less";
+import "./styles/brand-layout.css";
+
+// Brand component styles.
+import "./components/menu.less";
+import "./components/header.less";
+import "./components/search-input.less";
+import "./components/pagination.less";
+import "./components/dropdown-button.less";
+
+import App from "./App.vue";
+
+createApp(App).use(Antd).mount("#root");
diff --git a/src-vue/pages/Assets.vue b/src-vue/pages/Assets.vue
new file mode 100644 (file)
index 0000000..8127dc3
--- /dev/null
@@ -0,0 +1,284 @@
+<script setup>
+import { ref, computed, watch, onMounted, h } from "vue";
+import { Tag } from "ant-design-vue";
+import { EyeOutlined } from "@ant-design/icons-vue";
+
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
+import UiActions from "../ui/UiActions.vue";
+import Panel from "../components/Panel.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+import ManualAssetConfiguration from "./ManualAssetConfiguration.vue";
+
+import { fetchDb } from "../lib/api.js";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  onChange: { type: Function, default: null },
+  saving: { type: Boolean, default: false },
+  notify: { type: Function, default: () => {} },
+  onViewCompletePrompt: { type: Function, default: null },
+  mode: { type: String, default: "quick" }, // "quick" | "manual"
+  onModeChange: { type: Function, default: null },
+  go: { type: Function, default: null },
+});
+
+const notify = (m) => (props.notify ? props.notify(m) : null);
+const change = (assets) => props.onChange && props.onChange(assets);
+
+// ---- port of React local `Status` component ----
+const Status = (p, { slots }) => {
+  const def = slots.default ? slots.default() : [];
+  const v = (Array.isArray(def) ? def : [def])
+    .map((n) =>
+      typeof n === "string" || typeof n === "number"
+        ? String(n)
+        : n && n.children
+        ? String(n.children)
+        : ""
+    )
+    .join("");
+  const tone = /不可用|失败|异常|failed/.test(v)
+    ? "bad"
+    : /完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(v)
+    ? "ok"
+    : /生成中|校验中|告警|running/.test(v)
+    ? "warn"
+    : "muted";
+  return h(Tag, { class: "status " + tone }, slots.default);
+};
+
+// ---- device template loading (filtered by site domain) ----
+const deviceTemplates = ref([]);
+const loadDeviceTemplates = async () => {
+  try {
+    const all = (await fetchDb("/device-templates")) || [];
+    deviceTemplates.value = all.filter((t) => {
+      try {
+        return (JSON.parse(t.domain_names || "[]") || []).includes(props.site?.domain);
+      } catch {
+        return false;
+      }
+    });
+  } catch {
+    /* 静默失败,下拉框为空 */
+  }
+};
+onMounted(loadDeviceTemplates);
+watch(() => props.site?.domain, loadDeviceTemplates);
+
+// ---- AI asset completion console ----
+const completing = ref(false);
+const completeLog = ref("");
+const completeConsoleRef = ref(null);
+watch(completeLog, async () => {
+  await Promise.resolve();
+  if (completeConsoleRef.value)
+    completeConsoleRef.value.scrollTop = completeConsoleRef.value.scrollHeight;
+});
+const appendAssets = (items) => {
+  const base = props.site?.assets || [];
+  const next = [
+    ...base,
+    ...items.map((it, i) => ({
+      type: it.type,
+      count: Number(it.count) || 0,
+      rule: it.rule || "",
+      sort_order: base.length + i,
+    })),
+  ];
+  change(next);
+  notify(`AI 已补充 ${items.length} 类设备到资产配置(位于您已填项下方)`);
+};
+const runCompleteAssets = async () => {
+  if (!props.site?.id || completing.value) return;
+  completing.value = true;
+  completeLog.value = "";
+  const watchdog = setTimeout(() => {
+    completing.value = false;
+    notify("AI 资产补全超时(>6 分钟),请重试");
+  }, 360000);
+  try {
+    const res = await fetch(`/api/db/sites/${props.site.id}/complete-assets`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        name: props.site.name,
+        domain: props.site.domain,
+        description: props.site.description,
+        supplement: props.site.supplement,
+        assets: props.site.assets,
+      }),
+    });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    const reader = res.body.getReader();
+    const dec = new TextDecoder();
+    let buf = "";
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buf += dec.decode(value, { stream: true });
+      const lines = buf.split("\n");
+      buf = lines.pop() || "";
+      for (const line of lines) {
+        if (!line.trim()) continue;
+        try {
+          const ev = JSON.parse(line);
+          if (ev.log) completeLog.value += ev.log;
+          if (Array.isArray(ev.result)) appendAssets(ev.result);
+        } catch {}
+      }
+    }
+    notify("AI 资产补全已完成");
+  } catch (e) {
+    notify("AI 资产补全失败:" + (e?.message || e));
+  } finally {
+    clearTimeout(watchdog);
+    completing.value = false;
+  }
+};
+
+const matchedTemplate = (row) => deviceTemplates.value.find((t) => t.name === row.type);
+const onEye = (row) => {
+  const matched = matchedTemplate(row);
+  if (matched) {
+    if (props.go) props.go("deviceTemplates");
+    window.history.replaceState({}, "", "?page=deviceTemplates&openTemplate=" + matched.id);
+  }
+};
+
+// ---- table rows ----
+const rows = computed(() => {
+  const assets = props.site?.assets || [];
+  if (assets.length) return assets;
+  return Array.from({ length: 4 }, (_, i) => ({ type: "", count: 0, rule: "", sort_order: i }));
+});
+const total = computed(() => rows.value.reduce((n, a) => n + Number(a.count || 0), 0));
+const kpis = computed(() => [
+  [rows.value.length, "设备类型", "类"],
+  [total.value, "设备实例", "个"],
+  [Math.max(2, rows.value.filter((r) => r.type).length + 1), "资产层级", "层"],
+]);
+const update = (i, patch) => {
+  const next = rows.value.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
+  change(next);
+};
+const add = () => change([...rows.value, { type: "", count: 0, rule: "", sort_order: rows.value.length }]);
+const remove = (i) => change(rows.value.filter((_, idx) => idx !== i));
+</script>
+
+<template>
+  <div class="asset-config-shell" v-if="site">
+    <UiTabbedRegion>
+      <template #navigation>
+        <div class="asset-config-tabs" role="tablist" aria-label="资产配置方式">
+          <UiButton
+            role="tab"
+            :aria-selected="mode === 'quick'"
+            :class="mode === 'quick' ? 'active' : ''"
+            @click="() => props.onModeChange && props.onModeChange('quick')"
+          ><span>快速资产配置</span></UiButton>
+          <UiButton
+            role="tab"
+            :aria-selected="mode === 'manual'"
+            :class="mode === 'manual' ? 'active' : ''"
+            @click="() => props.onModeChange && props.onModeChange('manual')"
+          ><span>进阶配置</span></UiButton>
+        </div>
+      </template>
+
+      <ManualAssetConfiguration v-if="mode === 'manual'" :site="site" :notify="notify" />
+
+      <div v-else class="two-column quick-asset-config">
+        <div>
+          <div class="kpi-row">
+            <div v-for="x in kpis" :key="x[1]">
+              <span>{{ x[1] }}</span><b>{{ x[0] }}</b><small>{{ x[2] }}</small>
+            </div>
+            <div v-if="saving"><span>保存</span><Status>保存中…</Status></div>
+          </div>
+          <Panel title="设备资产配置">
+            <template #tools>
+              <PrimaryButton @click="add">+ 添加设备类型</PrimaryButton>
+            </template>
+            <UiTable class="data-table editable">
+              <thead>
+                <tr>
+                  <th>设备类型 *</th>
+                  <th></th>
+                  <th>数量 *</th>
+                  <th>命名规则</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="(row, i) in rows" :key="i" :class="!row.type ? 'empty-row' : ''">
+                  <td>
+                    <UiSelect
+                      v-if="deviceTemplates.length > 0"
+                      :value="row.type"
+                      @change="(e) => update(i, { type: e.target.value })"
+                    >
+                      <option value="">请选择设备模板</option>
+                      <option v-for="t in deviceTemplates" :key="t.id" :value="t.name">{{ t.name }}</option>
+                    </UiSelect>
+                    <UiInput
+                      v-else
+                      :value="row.type"
+                      placeholder="例如:冷水机组"
+                      @change="(e) => update(i, { type: e.target.value })"
+                    />
+                  </td>
+                  <td>
+                    <UiButton
+                      :class="'asset-eye-btn' + (matchedTemplate(row) ? '' : ' disabled')"
+                      :title="matchedTemplate(row) ? `查看模板:${matchedTemplate(row).name}` : '请先选择设备模板'"
+                      @click="() => onEye(row)"
+                    ><EyeOutlined /></UiButton>
+                  </td>
+                  <td>
+                    <UiInput
+                      class="number"
+                      type="number"
+                      min="0"
+                      :value="row.count || ''"
+                      placeholder="0"
+                      @change="(e) => update(i, { count: Number(e.target.value) })"
+                    />
+                  </td>
+                  <td>
+                    <UiInput
+                      :value="row.rule"
+                      placeholder="例如:CH-##"
+                      @change="(e) => update(i, { rule: e.target.value })"
+                    />
+                  </td>
+                  <td>
+                    <UiButton class="trash" @click="() => remove(i)">⌫</UiButton>
+                  </td>
+                </tr>
+              </tbody>
+            </UiTable>
+          </Panel>
+
+          <div class="asset-ai-console">
+            <PrimaryButton :disabled="completing" @click="runCompleteAssets">
+              {{ completing ? "补全中…" : "AI 资产补全" }}
+            </PrimaryButton>
+            <span class="muted">基于站点需求与已填资产,自动推荐设备类型、数量与命名规则</span>
+          </div>
+          <div v-if="completing || completeLog" class="modal-console">
+            <div class="modal-console-head">生成进度</div>
+            <pre class="modal-console-body" ref="completeConsoleRef">{{ completeLog || "(等待 AI 分析…)" }}</pre>
+          </div>
+        </div>
+      </div>
+    </UiTabbedRegion>
+  </div>
+  <Panel v-else>
+    <div class="empty-state">加载中…</div>
+  </Panel>
+</template>
diff --git a/src-vue/pages/Basic.vue b/src-vue/pages/Basic.vue
new file mode 100644 (file)
index 0000000..ea79aae
--- /dev/null
@@ -0,0 +1,245 @@
+<script setup>
+import { ref, computed, watch, nextTick, onMounted } from "vue";
+import { Drawer, Button } from "ant-design-vue";
+import { RobotOutlined, EyeOutlined } from "@ant-design/icons-vue";
+
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiActions from "../ui/UiActions.vue";
+import Panel from "../components/Panel.vue";
+import Field from "../components/Field.vue";
+import { countChars } from "../lib/api.js";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  domains: { type: Array, default: () => [] }, // string[]
+  onChange: { type: Function, default: null },
+  onAddDomain: { type: Function, default: null },
+  onUpload: { type: Function, default: null },
+  saving: { type: Boolean, default: false },
+  notify: { type: Function, default: () => {} },
+  onViewCompletePrompt: { type: Function, default: null },
+  go: { type: Function, default: null },
+  siteId: { type: [Number, null], default: null },
+  siteHierarchy: { type: Object, default: null },
+});
+
+const notify = (m) => (props.notify ? props.notify(m) : null);
+const change = (patch) => props.onChange && props.onChange(patch);
+
+const newDomain = ref("");
+const domainInput = ref(props.site?.domain || "");
+const showAdd = ref(false);
+const aiOpen = ref(false);
+const completing = ref(false);
+const completeLog = ref("");
+const completeConsoleRef = ref(null);
+
+watch(
+  () => props.site?.domain,
+  (v) => {
+    if (props.site) domainInput.value = v || "";
+  }
+);
+watch(completeLog, async () => {
+  await nextTick();
+  if (completeConsoleRef.value) completeConsoleRef.value.scrollTop = completeConsoleRef.value.scrollHeight;
+});
+
+const nameTooLong = computed(() => countChars(props.site?.name || "") > 20);
+const domainTooLong = computed(() => newDomain.value.length > 6);
+
+const runComplete = async () => {
+  if (!props.site?.id || completing.value) return;
+  completing.value = true;
+  completeLog.value = "";
+  const watchdog = setTimeout(() => {
+    completing.value = false;
+    notify("AI 智能补全超时(>6 分钟),请重试");
+  }, 360000);
+  try {
+    const res = await fetch(`/api/db/sites/${props.site.id}/complete`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        name: props.site.name,
+        domain: props.site.domain,
+        description: props.site.description,
+      }),
+    });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    const reader = res.body.getReader();
+    const dec = new TextDecoder();
+    let buf = "";
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buf += dec.decode(value, { stream: true });
+      const lines = buf.split("\n");
+      buf = lines.pop() || "";
+      for (const line of lines) {
+        if (!line.trim()) continue;
+        try {
+          const ev = JSON.parse(line);
+          if (ev.log) completeLog.value += ev.log;
+          if (ev.result) change({ supplement: ev.result });
+        } catch {}
+      }
+    }
+    notify("AI 智能补全已完成,已写入补充描述");
+  } catch (e) {
+    notify("AI 智能补全失败:" + (e?.message || e));
+  } finally {
+    clearTimeout(watchdog);
+    completing.value = false;
+  }
+};
+
+onMounted(() => {
+  if (props.site) domainInput.value = props.site.domain || "";
+});
+</script>
+
+<template>
+  <div v-if="site" class="company-form-page">
+    <aside class="form-section-nav">
+      <i class="form-section-nav-dot" />
+      <span>站点信息</span>
+      <a href="#site-basic">基本信息</a>
+      <a href="#site-description">情况描述</a>
+      <template v-if="siteHierarchy">
+        <i class="form-section-nav-dot" />
+        <span class="site-structure-title">站点结构</span>
+        <div class="site-structure">
+          <template v-for="(a, idx) in siteHierarchy.ancestors" :key="a.id">
+            <div class="tree-branch">
+              <div
+                class="tree-node"
+                :class="a.id === siteId ? 'current-site' : ''"
+                style="cursor: pointer"
+                @click="() => props.go && props.go('siteEdit', a.id)"
+              >
+                {{ a.name || "未命名站点" }}
+              </div>
+              <template v-if="idx === siteHierarchy.ancestors.length - 1">
+                <div v-for="c in siteHierarchy.children" :key="c.id" class="tree-branch">
+                  <div
+                    class="tree-node"
+                    :class="c.id === siteId ? 'current-site' : ''"
+                    style="cursor: pointer"
+                    @click="() => props.go && props.go('siteEdit', c.id)"
+                  >
+                    {{ c.name || "未命名站点" }}
+                  </div>
+                </div>
+              </template>
+            </div>
+          </template>
+        </div>
+      </template>
+    </aside>
+    <div class="single-column-form">
+      <Panel title="站点基本信息">
+        <div class="form-grid" id="site-basic">
+          <Field label="站点名称" required hint="最多 20 个汉字">
+            <UiInput
+              :value="site.name"
+              :maxLength="20"
+              :class="nameTooLong ? 'invalid' : ''"
+              @change="(e) => change({ name: e.target.value })"
+            />
+          </Field>
+          <Field label="所属领域" required>
+            <div class="domain-select">
+              <UiInput
+                list="domain-list"
+                :value="domainInput"
+                placeholder="选择或输入新领域"
+                @change="(e) => { domainInput = e.target.value; change({ domain: e.target.value }); }"
+              />
+              <datalist id="domain-list">
+                <option v-for="d in domains" :key="d" :value="d" />
+              </datalist>
+              <UiButton className="text-button" @click="() => (showAdd = !showAdd)">
+                {{ showAdd ? "收起新增" : "新增领域" }}
+              </UiButton>
+            </div>
+            <div v-if="showAdd" class="domain-add">
+              <UiInput
+                :value="newDomain"
+                :maxLength="6"
+                placeholder="新领域名称(最多6字)"
+                @change="(e) => (newDomain = e.target.value)"
+              />
+              <Button
+                secondary
+                :disabled="!newDomain || domainTooLong"
+                @click="() => { if (props.onAddDomain) props.onAddDomain(newDomain); newDomain = ''; showAdd = false; }"
+              >
+                保存
+              </Button>
+            </div>
+          </Field>
+          <div class="form-section-heading" id="site-description">
+            <h3>情况描述</h3>
+            <p>说明站点用途、重点设备、管理目标及关注指标。</p>
+          </div>
+          <Field label="站点基本情况描述">
+            <UiInput textarea :value="site.description" @change="(e) => change({ description: e.target.value })" />
+          </Field>
+          <div class="field-assist">
+            <Button secondary @click="() => (aiOpen = true)"><RobotOutlined /> AI 智能补全</Button>
+            <span class="muted">补充要求、填写建议与参考资料均在右侧抽屉中维护</span>
+          </div>
+          <div v-if="saving" class="save-indicator">正在保存…</div>
+        </div>
+      </Panel>
+    </div>
+    <Drawer title="AI 智能补全" :width="440" :open="aiOpen" :onClose="() => (aiOpen = false)">
+      <div class="ai-assist-drawer">
+        <div class="ai-assist-intro">
+          <RobotOutlined />
+          <div>
+            <h3>补充站点需求</h3>
+            <p>AI 将根据站点名称、所属领域和基本情况描述生成补充要求。生成结果会写入下方,仍可继续编辑。</p>
+          </div>
+        </div>
+        <Field label="补充要求">
+          <UiInput textarea class="small" :value="site.supplement" @change="(e) => change({ supplement: e.target.value })" />
+        </Field>
+        <div class="form-guidance">
+          <strong>填写建议</strong>
+          <span>站点承担什么业务?哪些设备最重要?管理人员关注哪些指标?首页首先需要看到什么?</span>
+        </div>
+        <div class="form-section-heading" id="site-files">
+          <h3>参考资料</h3>
+          <p>上传需求文档、设备清单或参考图片,帮助生成结果贴近真实需求。</p>
+        </div>
+        <Field label="文件上传">
+          <label class="upload">
+            <input type="file" hidden @change="(e) => { const f = e.target.files?.[0]; if (f && props.onUpload) props.onUpload(f); }" />
+            <b>+</b><span>点击或拖拽上传文件</span>
+            <small>支持 PDF、DOCX、XLSX、PNG,单个文件不超过 20 MB</small>
+          </label>
+          <ul v-if="site.files && site.files.length" class="file-list">
+            <li v-for="f in site.files" :key="f.id">
+              <a :href="`/api/db/sites/${site.id}/files/${f.id}`" download>{{ f.filename }}</a>
+              <small>({{ (f.size / 1024).toFixed(1) }} KB)</small>
+            </li>
+          </ul>
+        </Field>
+        <UiActions className="drawer-actions">
+          <Button :disabled="completing" @click="runComplete">{{ completing ? "补全中…" : "开始补全" }}</Button>
+          <Button secondary @click="() => props.onViewCompletePrompt && props.onViewCompletePrompt()">查看提示词</Button>
+        </UiActions>
+        <div v-if="completing || completeLog" class="modal-console">
+          <div class="modal-console-head">生成进度</div>
+          <pre class="modal-console-body" ref="completeConsoleRef">{{ completeLog || "(等待 AI 分析…)" }}</pre>
+        </div>
+      </div>
+    </Drawer>
+  </div>
+  <Panel v-else>
+    <div class="empty-state">加载中…</div>
+  </Panel>
+</template>
diff --git a/src-vue/pages/Dashboard.vue b/src-vue/pages/Dashboard.vue
new file mode 100644 (file)
index 0000000..2b21815
--- /dev/null
@@ -0,0 +1,5 @@
+<template>
+  <div class="content dashboard-page">
+    <img src="/t/stationbuilder/dashboard-preview.jpg" alt="仪表盘示意" loading="lazy" />
+  </div>
+</template>
diff --git a/src-vue/pages/DeviceTemplates.vue b/src-vue/pages/DeviceTemplates.vue
new file mode 100644 (file)
index 0000000..2d9365c
--- /dev/null
@@ -0,0 +1,251 @@
+<script setup>
+import { ref, computed, watch, onMounted, h } from "vue";
+import { Button as AntButton, Dropdown, Tag } from "ant-design-vue";
+import { EditOutlined, DeleteOutlined, EllipsisOutlined } from "@ant-design/icons-vue";
+import UiButton from "../ui/UiButton.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
+import UiActions from "../ui/UiActions.vue";
+import Panel from "../components/Panel.vue";
+import SearchInput from "../components/SearchInput.vue";
+import StatusTag from "../components/StatusTag.vue";
+import Pagination from "../components/Pagination.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+import DeviceTemplateCreate from "../pages/domain/DeviceTemplateCreate.vue";
+import { DeviceTemplateCard } from "../components/DeviceTemplateCard.js";
+import { fetchDb, domainTagColor } from "../lib/api.js";
+import { useConfirm } from "../pages/domain/useConfirm.js";
+
+const props = defineProps({
+  notify: { type: Function, default: () => {} },
+  onViewCpPrompt: { type: Function, default: () => {} },
+  go: { type: Function, default: () => {} },
+  siteId: { type: Number, default: null },
+  domainOptions: { type: Array, default: () => [] },
+  initialDomain: { type: String, default: "" },
+});
+
+const list = ref([]);
+const loading = ref(true);
+const openId = ref(null); // number | "create" | null
+const query = ref("");
+const domainFilter = ref(props.initialDomain || "");
+const pointFilter = ref("");
+const pageIndex = ref(1);
+const pageSize = ref(10);
+const fromSite = ref(false);
+const { confirm, el: confirmEl } = useConfirm();
+
+async function load() {
+  try {
+    list.value = (await fetchDb("/device-templates")) || [];
+  } catch (e) {
+    props.notify("加载失败:" + (e && e.message ? e.message : e));
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(() => {
+  load();
+  const p = new URLSearchParams(window.location.search);
+  const tid = p.get("openTemplate");
+  if (tid) {
+    fromSite.value = true;
+    openId.value = Number(tid);
+    window.history.replaceState({}, "", "?page=deviceTemplates");
+  }
+});
+
+watch(
+  () => props.initialDomain,
+  (v) => {
+    domainFilter.value = v || "";
+  }
+);
+
+function pointCount(row) {
+  return (row.collection_points || []).length;
+}
+function domainNames(row) {
+  if (Array.isArray(row.domain_names)) return row.domain_names.map(String);
+  try {
+    return JSON.parse(row.domain_names || "[]");
+  } catch {
+    return [];
+  }
+}
+
+async function remove(row) {
+  if (!(await confirm(`确定删除设备模板「${row.name}」?`))) return;
+  try {
+    await fetchDb(`/device-templates/${row.id}`, { method: "DELETE" });
+    await load();
+    props.notify("设备模板已删除");
+  } catch (e) {
+    props.notify("删除失败:" + (e && e.message ? e.message : e));
+  }
+}
+
+const counts = computed(() => ({
+  all: list.value.length,
+  configured: list.value.filter((x) => pointCount(x) > 0).length,
+  empty: list.value.filter((x) => pointCount(x) === 0).length,
+}));
+
+const filtered = computed(() =>
+  list.value.filter((row) => {
+    if (pointFilter.value) {
+      const ok = pointFilter.value === "configured" ? pointCount(row) > 0 : pointCount(row) === 0;
+      if (!ok) return false;
+    }
+    if (domainFilter.value && !domainNames(row).includes(domainFilter.value)) return false;
+    if (
+      query.value.trim() &&
+      !`${row.name} ${row.description || ""}`.toLowerCase().includes(query.value.trim().toLowerCase())
+    )
+      return false;
+    return true;
+  })
+);
+
+watch([pointFilter, domainFilter, query], () => {
+  pageIndex.value = 1;
+});
+
+const tabs = computed(() => [
+  { key: "", label: "全部", count: counts.value.all },
+  { key: "configured", label: "已配置采集点", count: counts.value.configured },
+  { key: "empty", label: "待配置", count: counts.value.empty },
+]);
+
+const dev = computed(() =>
+  typeof openId.value === "number" ? list.value.find((d) => d.id === openId.value) : null
+);
+
+function back() {
+  openId.value = null;
+  if (fromSite.value && props.siteId != null) props.go("siteEdit", props.siteId);
+}
+
+function rowMenu(row) {
+  return {
+    items: [
+      { key: "edit", label: "编辑模板", icon: h(EditOutlined) },
+      { key: "delete", label: "删除", danger: true, icon: h(DeleteOutlined) },
+    ],
+    onClick: ({ key }) => (key === "edit" ? (openId.value = row.id) : remove(row)),
+  };
+}
+</script>
+
+<template>
+  <template v-if="openId === 'create'">
+    <div class="resource-back">
+      <UiButton class="text-button" @click="back">{{ fromSite ? "← 返回站点编辑" : "← 返回设备模板列表" }}</UiButton>
+    </div>
+    <DeviceTemplateCreate
+      :notify="notify"
+      :on-done="async (id) => { await load(); openId = id; }"
+      :on-cancel="() => (openId = null)"
+    />
+  </template>
+  <template v-else-if="typeof openId === 'number'">
+    <div class="resource-back">
+      <UiButton class="text-button" @click="back">{{ fromSite ? "← 返回站点编辑" : "← 返回设备模板列表" }}</UiButton>
+    </div>
+    <DeviceTemplateCard
+      v-if="dev"
+      :dev="dev"
+      :notify="notify"
+      :on-change="load"
+      :on-back="() => (openId = null)"
+      :on-view-cp-prompt="onViewCpPrompt"
+    />
+    <div v-else class="empty-state">加载中…</div>
+  </template>
+  <template v-else>
+    <component :is="confirmEl" />
+    <UiTabbedRegion>
+      <template #navigation>
+        <div class="company-status-tabs" role="tablist" aria-label="设备模板状态筛选">
+          <UiButton
+            v-for="item in tabs"
+            :key="item.key || 'all'"
+            role="tab"
+            :aria-selected="pointFilter === item.key"
+            :class="pointFilter === item.key ? 'active' : ''"
+            @click="pointFilter = item.key"
+          >
+            <span>{{ item.label }}</span><b>{{ item.count }}</b>
+          </UiButton>
+        </div>
+      </template>
+      <Panel class="company-list-panel resource-list-panel">
+        <div class="filterbar company-filterbar">
+          <SearchInput :value="query" placeholder="搜索设备名称或说明" @change="(v) => (query = v)" />
+          <UiSelect aria-label="领域筛选" :value="domainFilter" @change="(e) => (domainFilter = e.target.value)">
+            <option value="">全部领域</option>
+            <option v-for="domain in domainOptions" :key="domain.id" :value="domain.name">{{ domain.name }}</option>
+          </UiSelect>
+          <UiActions class="filter-actions">
+            <AntButton @click="() => { query = ''; pointFilter = ''; domainFilter = ''; }">重置</AntButton>
+            <PrimaryButton @click="openId = 'create'">+ 新增模板</PrimaryButton>
+          </UiActions>
+        </div>
+        <template v-if="loading">
+          <div class="empty-state">加载中…</div>
+        </template>
+        <template v-else>
+          <UiTable class="data-table company-table resource-table device-resource-table">
+            <thead>
+              <tr>
+                <th>设备模板名称</th>
+                <th>采集点</th>
+                <th>配置状态</th>
+                <th>所属领域</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr
+                v-for="row in filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize)"
+                :key="row.id"
+                @click="openId = row.id"
+              >
+                <td>
+                  <UiButton class="link company-record-link" :title="row.name" @click.stop="openId = row.id">
+                    <b>{{ row.name || "未命名设备模板" }}</b>
+                  </UiButton>
+                </td>
+                <td>{{ pointCount(row) }}</td>
+                <td>
+                  <StatusTag :value="pointCount(row) > 0 ? '已配置' : '待配置'" />
+                </td>
+                <td class="domain-cell">
+                  <div class="domain-tag-list">
+                    <Tag v-for="n in domainNames(row)" :key="n" class="tag-new" :color="domainTagColor(n)">{{ n }}</Tag>
+                  </div>
+                </td>
+                <td @click.stop>
+                  <Dropdown :trigger="['click']" :menu="rowMenu(row)">
+                    <AntButton type="text" aria-label="更多操作" :icon="h(EllipsisOutlined)" />
+                  </Dropdown>
+                </td>
+              </tr>
+            </tbody>
+          </UiTable>
+          <div v-if="filtered.length === 0" class="empty-state">暂无符合条件的设备模板</div>
+          <Pagination
+            :current="pageIndex"
+            :total-pages="Math.max(1, Math.ceil(filtered.length / pageSize))"
+            :page-size="pageSize"
+            @page-change="(p) => (pageIndex = p)"
+            @page-size-change="(s) => { pageSize = s; pageIndex = 1; }"
+          />
+        </template>
+      </Panel>
+    </UiTabbedRegion>
+  </template>
+</template>
diff --git a/src-vue/pages/DomainSettings.vue b/src-vue/pages/DomainSettings.vue
new file mode 100644 (file)
index 0000000..7120db3
--- /dev/null
@@ -0,0 +1,610 @@
+<script setup>
+import { ref, computed, watch, onMounted, h } from "vue";
+import { Drawer, Button as AntButton } from "ant-design-vue";
+import {
+  EditOutlined, DeleteOutlined, CopyOutlined, PlusOutlined, RobotOutlined,
+  SearchOutlined, ControlOutlined, EllipsisOutlined,
+} from "@ant-design/icons-vue";
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiActions from "../ui/UiActions.vue";
+import Panel from "../components/Panel.vue";
+import Field from "../components/Field.vue";
+import StatusTag from "../components/StatusTag.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+import { fetchDb, loadRenderer } from "../lib/api.js";
+import { MODELS } from "../lib/meta.js";
+import { useConfirm } from "../pages/domain/useConfirm.js";
+import { DeviceImageField, DeviceTemplateCard, CP_RANGE_PRESETS, fileToBase64 } from "../components/DeviceTemplateCard.js";
+
+// React local `Button` (AntButton primary/default + "button" class).
+const Button = PrimaryButton;
+// React `Status` (Tag with tone class).
+const Status = StatusTag;
+
+const CURRENT_USER_EMAIL = "wang.yu@advantech.com";
+function useStreamComplete() {
+  const completing = ref(false);
+  const log = ref("");
+  const preRef = ref(null);
+  watch(log, () => { if (preRef.value) preRef.value.scrollTop = preRef.value.scrollHeight; });
+  const setCompleting = (v) => (completing.value = typeof v === "function" ? v(completing.value) : v);
+  const setLog = (v) => (log.value = typeof v === "function" ? v(log.value) : v);
+  return { completing, setCompleting, log, setLog, ref: preRef };
+}
+
+async function streamInto(res, setLog, onEv) {
+  const reader = res.body.getReader();
+  const dec = new TextDecoder();
+  let buf = "";
+  for (;;) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    buf += dec.decode(value, { stream: true });
+    const lines = buf.split("\n");
+    buf = lines.pop() || "";
+    for (const line of lines) {
+      if (!line.trim()) continue;
+      try {
+        const ev = JSON.parse(line);
+        if (ev.log) setLog((prev) => prev + ev.log);
+        onEv(ev);
+      } catch {}
+    }
+  }
+}
+
+// ---- root component (DomainSettings) props / behavior ----
+const props = defineProps({
+  domains: { type: Array, default: () => [] },
+  notify: { type: Function, default: () => {} },
+  onDeleted: { type: Function, default: () => Promise.resolve() },
+});
+
+const { confirm, el: confirmEl } = useConfirm();
+
+async function remove(row) {
+  if (!(await confirm(`确定删除领域「${row.name}」?其下的方案将移入「其他」领域。`))) return;
+  try {
+    await fetchDb(`/domains/${row.id}`, { method: "DELETE" });
+    await props.onDeleted(row.id);
+    props.notify("已删除领域,相关方案已归入「其他」");
+  } catch (e) {
+    props.notify("删除失败:" + (e && e.message ? e.message : e));
+  }
+}
+
+// ============================================================================
+// Co-located port of every component defined in app/page.tsx lines 1543–1928.
+// (DomainLibrary, CreateTemplate, DeviceImageField, DeviceTemplateCard,
+//  DeviceTemplateCreate, DeviceTemplates, DeviceTemplatePicker, PageRow,
+//  OpsFlowsViewer)
+// ============================================================================
+
+// ---- CreateTemplate (1648-1663) ----
+const CreateTemplate = {
+  props: {
+    doms: { type: Array, default: () => [] },
+    notify: { type: Function, default: () => {} },
+    onCreated: { type: Function, default: () => {} },
+  },
+  setup(p) {
+    const domain_id = ref(p.doms[0] ? p.doms[0].id : 0);
+    const name = ref("");
+    const description = ref("");
+    const status = ref("available");
+    const version = ref("1.0");
+    const creator = ref(CURRENT_USER_EMAIL);
+    watch(
+      () => p.doms,
+      (d) => { if (d.length && !domain_id.value) domain_id.value = d[0].id; },
+      { immediate: true }
+    );
+    const save = async () => {
+      if (!domain_id.value) { p.notify("请选择所属领域"); return; }
+      try {
+        const r = await fetchDb("/domain-templates", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            domain_id: domain_id.value, name: name.value, description: description.value,
+            status: status.value, version: version.value, creator: creator.value,
+          }),
+        });
+        p.notify("已创建");
+        p.onCreated(r.id);
+      } catch (e) {
+        p.notify("创建失败:" + (e && e.message ? e.message : e));
+      }
+    };
+    return () =>
+      h(Panel, { title: "新增领域方案" }, () =>
+        h("div", { class: "form-grid" }, [
+          h("label", {}, [
+            "所属领域",
+            h(UiSelect, { value: domain_id.value, onChange: (e) => (domain_id.value = Number(e.target.value)) }, () =>
+              p.doms.map((d) => h("option", { key: d.id, value: d.id }, d.name))
+            ),
+          ]),
+          h("label", {}, [
+            "方案名称",
+            h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value), placeholder: "如:自来水厂标准方案" }),
+          ]),
+          h("label", {}, ["模板版本", h(UiInput, { value: version.value, onChange: (e) => (version.value = e.target.value) })]),
+          h("label", {}, [
+            "状态",
+            h(UiSelect, { value: status.value, onChange: (e) => (status.value = e.target.value) }, () => [
+              h("option", { value: "available" }, "可用"),
+              h("option", { value: "unavailable" }, "不可用"),
+            ]),
+          ]),
+          h("label", {}, ["创建人", h(UiInput, { value: creator.value, onChange: (e) => (creator.value = e.target.value) })]),
+          h("label", { style: { gridColumn: "1 / -1" } }, [
+            "领域描述",
+            h(UiInput, { textarea: true, value: description.value, onChange: (e) => (description.value = e.target.value), rows: 3 }),
+          ]),
+          h("div", { style: { gridColumn: "1 / -1" } }, [h(Button, { onClick: save }, "创建")]),
+        ])
+      );
+  },
+};
+
+// ---- DomainLibrary (1559-1644) ----
+const DomainLibrary = {
+  props: {
+    go: { type: Function, default: () => {} },
+    notify: { type: Function, default: () => {} },
+    onOpen: { type: Function, default: () => {} },
+    domainFilter: { type: Number, default: null },
+    domainOptions: { type: Array, default: () => [] },
+  },
+  setup(p) {
+    const list = ref([]);
+    const loading = ref(true);
+    const view = ref("list");
+    const q = ref("");
+    const domainSel = ref(p.domainFilter != null ? String(p.domainFilter) : "");
+    const statusSel = ref("");
+    const sort = ref({ key: "", dir: 1 });
+    const { confirm, el: confirmEl } = useConfirm();
+
+    const load = async () => {
+      try {
+        list.value = (await fetchDb("/domain-templates")) || [];
+      } catch (e) {
+        p.notify("加载失败:" + (e && e.message ? e.message : e));
+      } finally {
+        loading.value = false;
+      }
+    };
+    onMounted(load);
+
+    const del = async (id) => {
+      if (!(await confirm("确定删除该领域方案?此操作不可恢复"))) return;
+      try {
+        await fetchDb(`/domain-templates/${id}`, { method: "DELETE" });
+        p.notify("已删除");
+        load();
+      } catch (e) {
+        p.notify("删除失败:" + (e && e.message ? e.message : e));
+      }
+    };
+
+    const statusLabel = (s) => (s === "available" ? "可用" : "不可用");
+    const toggleSort = (key) =>
+      sort.value = sort.value.key === key ? { key, dir: sort.value.dir * -1 } : { key, dir: 1 };
+    const sortInd = (key) =>
+      sort.value.key === key ? h("span", { class: "sort-ind" }, sort.value.dir === 1 ? "▲" : "▼") : null;
+
+    const filtered = computed(() => {
+      let arr = list.value
+        .filter((t) => domainSel.value === "" || String(t.domain_id) === domainSel.value)
+        .filter((t) => statusSel.value === "" || t.status === statusSel.value)
+        .filter((t) => {
+          const k = q.value.trim().toLowerCase();
+          if (!k) return true;
+          return (t.name || "").toLowerCase().includes(k) || (t.creator || "").toLowerCase().includes(k);
+        });
+      arr = arr.slice().sort((a, b) => {
+        if (!sort.value.key) return 0;
+        let av, bv;
+        if (sort.value.key === "domain") { av = a.domain_name; bv = b.domain_name; }
+        else if (sort.value.key === "name") { av = a.name; bv = b.name; }
+        else if (sort.value.key === "status") { av = statusLabel(a.status); bv = statusLabel(b.status); }
+        else { av = a.creator || ""; bv = b.creator || ""; }
+        av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
+        if (av < bv) return -1 * sort.value.dir;
+        if (av > bv) return 1 * sort.value.dir;
+        return 0;
+      });
+      return arr;
+    });
+
+    return () => [
+      h(confirmEl),
+      h("div", { class: "domain-hero" }, [
+        h("div", {}, [
+          h("span", { class: "eyebrow" }, "DOMAIN SOLUTION LIBRARY"),
+          h("h2", {}, "领域方案库"),
+          h("p", {}, "沉淀各领域标准方案,供客户站点直接使用或快速微调。"),
+        ]),
+        h("div", { class: "domain-hero-stats" }, p.domainOptions.map((d) =>
+          h("div", { key: d.id }, [h("b", {}, list.value.filter((t) => t.domain_id === d.id).length), h("span", {}, d.name)])
+        )),
+      ]),
+      h(Panel, {
+        title: "领域方案列表",
+        tools: () =>
+          h("div", { class: "view-toggle" }, [
+            h(UiButton, { class: view.value === "list" ? "active" : "", onClick: () => (view.value = "list") }, "▤ 列表"),
+            h(UiButton, { class: view.value === "card" ? "active" : "", onClick: () => (view.value = "card") }, "▦ 卡片"),
+          ]),
+      }, () =>
+        h("div", {}, [
+          h("div", { class: "filter-bar" }, [
+            h(UiInput, { class: "filter-search", value: q.value, onChange: (e) => (q.value = e.target.value), placeholder: "查找 方案名称 / 创建人" }),
+            h(UiSelect, { value: domainSel.value, onChange: (e) => (domainSel.value = e.target.value) }, () => [
+              h("option", { value: "" }, "全部所属领域"),
+              ...p.domainOptions.map((d) => h("option", { key: d.id, value: String(d.id) }, d.name)),
+            ]),
+            h(UiSelect, { value: statusSel.value, onChange: (e) => (statusSel.value = e.target.value) }, () => [
+              h("option", { value: "" }, "全部状态"),
+              h("option", { value: "available" }, "可用"),
+              h("option", { value: "unavailable" }, "不可用"),
+            ]),
+            h("span", { class: "filter-count muted" }, `共 ${filtered.value.length} 条`),
+          ]),
+          loading.value
+            ? h("p", {}, "加载中…")
+            : filtered.value.length === 0
+            ? h("p", { class: "muted" }, "暂无方案,点击右上角「+ 新增领域方案」创建")
+            : view.value === "list"
+            ? h(UiTable, { class: "data-table domain-list" }, () => [
+                h("thead", {}, h("tr", {}, [
+                  h("th", { class: "sortable", style: { width: 160 }, onClick: () => toggleSort("domain") }, ["所属领域", sortInd("domain")]),
+                  h("th", { class: "sortable", style: { width: "40%" }, onClick: () => toggleSort("name") }, ["方案名称", sortInd("name")]),
+                  h("th", { style: { width: 64 } }, "版本"),
+                  h("th", { class: "sortable", style: { width: 82 }, onClick: () => toggleSort("status") }, ["状态", sortInd("status")]),
+                  h("th", { class: "sortable", style: { width: 140 }, onClick: () => toggleSort("creator") }, ["创建人", sortInd("creator")]),
+                  h("th", { style: { width: 110 } }, "设备/页面/文档"),
+                  h("th", { style: { width: 120 } }, "操作"),
+                ])),
+                h("tbody", {}, filtered.value.map((t) =>
+                  h("tr", { key: t.id }, [
+                    h("td", {}, t.domain_name),
+                    h("td", {}, t.name),
+                    h("td", {}, t.version),
+                    h("td", {}, h(Status, { value: t.status === "available" ? "可用" : "不可用" })),
+                    h("td", {}, t.creator || "—"),
+                    h("td", {}, `${t.device_count}/${t.page_count}/${t.doc_count}`),
+                    h("td", {}, [
+                      h(UiButton, { class: "text-button", onClick: () => p.onOpen(t.id) }, "编辑"),
+                      " ",
+                      h(UiButton, { class: "text-button", onClick: () => del(t.id) }, "删除"),
+                    ]),
+                  ])
+                )),
+              ])
+            : h("div", { class: "domain-card-grid" }, filtered.value.map((t) =>
+                h("article", { key: t.id, class: "domain-card", onClick: () => p.onOpen(t.id) }, [
+                  h("header", {}, [h("h3", {}, t.name), h(Status, { value: t.status === "available" ? "可用" : "不可用" })]),
+                  h("span", { class: "badge" }, t.domain_name),
+                  h("div", { class: "meta" }, [h("span", {}, [h("b", {}, t.version)]), h("span", {}, ["创建人 ", h("b", {}, t.creator || "—")])]),
+                  h("div", { class: "meta" }, [
+                    h("span", {}, ["设备 ", h("b", {}, t.device_count)]),
+                    h("span", {}, ["页面 ", h("b", {}, t.page_count)]),
+                    h("span", {}, ["文档 ", h("b", {}, t.doc_count)]),
+                  ]),
+                  h("footer", { class: "card-actions" }, [
+                    h(UiButton, { class: "text-button", onClick: (e) => { e.stopPropagation(); p.onOpen(t.id); } }, "编辑"),
+                    h(UiButton, { class: "text-button", onClick: (e) => { e.stopPropagation(); del(t.id); } }, "删除"),
+                  ]),
+                ])
+              )),
+        ])
+      ),
+    ];
+  },
+};
+
+// ---- DeviceTemplateCreate (1830-1842) ----
+const DeviceTemplateCreate = {
+  props: {
+    notify: { type: Function, default: () => {} },
+    onDone: { type: Function, default: () => {} },
+    onCancel: { type: Function, default: () => {} },
+  },
+  setup(p) {
+    const name = ref("");
+    const description = ref("");
+    const imgPreview = ref(null);
+    const imgB64 = ref(null);
+    const onPick = async (f) => {
+      imgPreview.value = URL.createObjectURL(f);
+      const b = await fileToBase64(f);
+      imgB64.value = { filename: b.filename, mime: b.mime, data: b.data };
+    };
+    const add = async () => {
+      if (!name.value.trim()) { p.notify("请填写设备名称"); return; }
+      try {
+        const body = { name: name.value, description: description.value };
+        if (imgB64.value) body.image = imgB64.value;
+        const r = await fetchDb("/device-templates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
+        p.notify("已创建设备模版");
+        p.onDone(r.id);
+      } catch (e) {
+        p.notify("创建失败:" + (e && e.message ? e.message : e));
+      }
+    };
+    return () =>
+      h(Panel, {
+        title: "新增设备模版",
+        tools: () => h(UiButton, { class: "text-button", onClick: p.onCancel }, "取消"),
+      }, () =>
+        h("div", { class: "form-grid" }, [
+          h("label", {}, ["设备名称", h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value), placeholder: "如:PLC 控制器" })]),
+          h("label", { style: { gridColumn: "1 / -1" } }, ["设备图片", h(DeviceImageField, { imageUrl: imgPreview.value, onPick })]),
+          h("label", { style: { gridColumn: "1 / -1" } }, ["说明", h(UiInput, { textarea: true, value: description.value, onChange: (e) => (description.value = e.target.value), rows: 2 })]),
+          h("div", { style: { gridColumn: "1 / -1" } }, [h(Button, { onClick: add }, "创建")]),
+        ])
+      );
+  },
+};
+
+// ---- DeviceTemplates (1844-1883) ----
+const DeviceTemplates = {
+  props: {
+    notify: { type: Function, default: () => {} },
+    onViewCpPrompt: { type: Function, default: () => {} },
+  },
+  setup(p) {
+    const list = ref([]);
+    const loading = ref(true);
+    const openId = ref(null);
+    const q = ref("");
+    const { confirm, el: confirmEl } = useConfirm();
+
+    const load = async () => {
+      try {
+        list.value = (await fetchDb("/device-templates")) || [];
+      } catch (e) {
+        p.notify("加载失败:" + (e && e.message ? e.message : e));
+      } finally {
+        loading.value = false;
+      }
+    };
+    onMounted(load);
+
+    const filtered = computed(() =>
+      list.value.filter((d) => {
+        if (!q.value.trim()) return true;
+        const s = q.value.toLowerCase();
+        return (d.name || "").toLowerCase().includes(s) || (d.description || "").toLowerCase().includes(s);
+      })
+    );
+    const delDev = async (id) => {
+      if (!(await confirm("删除该设备模板?"))) return;
+      await fetchDb(`/device-templates/${id}`, { method: "DELETE" });
+      load();
+    };
+    const dev = computed(() => (typeof openId.value === "number" ? list.value.find((d) => d.id === openId.value) : null));
+
+    return () => {
+      if (openId.value === null) {
+        return [
+          h(confirmEl),
+          h("div", { class: "domain-hero" }, [
+            h("div", {}, [h("span", { class: "eyebrow" }, "DEVICE TEMPLATE LIBRARY"), h("h2", {}, "设备模版"), h("p", {}, "独立维护设备模版及其采集点,编辑后可在领域方案中按需选用。")]),
+          ]),
+          h(Panel, {
+            title: "设备模版",
+            tools: () => h(Button, { onClick: () => (openId.value = "create") }, "+ 新增设备模版"),
+          }, () =>
+            h("div", {}, [
+              h("div", { class: "list-toolbar" }, [h(UiInput, { class: "search", placeholder: "搜索设备名称 / 说明", value: q.value, onChange: (e) => (q.value = e.target.value) })]),
+              loading.value
+                ? h("p", {}, "加载中…")
+                : filtered.value.length === 0
+                ? h("p", { class: "muted" }, list.value.length === 0 ? "暂无设备模版,点击「+ 新增设备模版」创建。" : "无匹配结果")
+                : h(UiTable, { class: "data-table device-tpl-list" }, () => [
+                    h("thead", {}, h("tr", {}, [h("th", {}, "图片"), h("th", {}, "名称"), h("th", {}, "说明"), h("th", {}, "采集点"), h("th", {}, "操作")])),
+                    h("tbody", {}, filtered.value.map((d) =>
+                      h("tr", { key: d.id, onClick: () => (openId.value = d.id) }, [
+                        h("td", {}, d.image_path ? h("img", { class: "tpl-thumb", src: `/api/db/device-templates/${d.id}/image`, alt: "" }) : h("span", { class: "tpl-thumb-empty" }, "—")),
+                        h("td", {}, d.name),
+                        h("td", { class: "muted" }, d.description || ""),
+                        h("td", {}, (d.collection_points || []).length),
+                        h("td", { onClick: (e) => e.stopPropagation() }, [
+                          h(UiButton, { class: "text-button", onClick: () => (openId.value = d.id) }, "编辑"),
+                          " ",
+                          h(UiButton, { class: "text-button", onClick: () => delDev(d.id) }, "删除"),
+                        ]),
+                      ])
+                    )),
+                  ]),
+            ])
+          ),
+        ];
+      }
+      if (openId.value === "create") {
+        return [
+          h("div", { class: "domain-detail-head" }, [h(UiButton, { class: "text-button", onClick: () => (openId.value = null) }, "← 返回列表")]),
+          h(DeviceTemplateCreate, {
+            notify: p.notify,
+            onDone: async (id) => { await load(); openId.value = id; },
+            onCancel: () => (openId.value = null),
+          }),
+        ];
+      }
+      const dv = dev.value;
+      if (!dv) return h("p", {}, "加载中…");
+      return [
+        h("div", { class: "domain-detail-head" }, [h(UiButton, { class: "text-button", onClick: () => (openId.value = null) }, "← 返回列表")]),
+        h(DeviceTemplateCard, { dev: dv, notify: p.notify, onChange: load, onBack: () => (openId.value = null), onViewCpPrompt: p.onViewCpPrompt }),
+      ];
+    };
+  },
+};
+
+// ---- DeviceTemplatePicker (1885-1909) ----
+const DeviceTemplatePicker = {
+  props: {
+    tmplId: { type: Number, default: 0 },
+    go: { type: Function, default: () => {} },
+    selectedIds: { type: Array, default: () => [] },
+    notify: { type: Function, default: () => {} },
+    onChange: { type: Function, default: () => {} },
+  },
+  setup(p) {
+    const all = ref([]);
+    const sel = ref(new Set(p.selectedIds));
+    watch(
+      () => p.selectedIds,
+      (v) => (sel.value = new Set(v)),
+      { immediate: true }
+    );
+    onMounted(() => {
+      fetchDb("/device-templates").then((d) => (all.value = d || [])).catch(() => {});
+    });
+    const toggle = async (id, checked) => {
+      const next = new Set(sel.value);
+      if (checked) next.add(id); else next.delete(id);
+      sel.value = next;
+      try {
+        await fetchDb(`/domain-templates/${p.tmplId}`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ device_template_ids: [...next] }),
+        });
+        p.notify(checked ? "已选用设备模版" : "已移除设备模版");
+        p.onChange();
+      } catch (e) {
+        p.notify("保存失败:" + (e && e.message ? e.message : e));
+        const back = new Set(sel.value);
+        if (checked) back.delete(id); else back.add(id);
+        sel.value = back;
+      }
+    };
+    return () =>
+      h("div", { class: "device-picker" }, [
+        all.value.length === 0
+          ? h("p", { class: "muted" }, "暂无设备模版,点击下方「+ 新增设备模版」创建后选用。")
+          : all.value.map((d) => {
+              let doms = [];
+              try { doms = JSON.parse(d.domain_names || "[]"); } catch {}
+              return h("label", { key: d.id, class: "picker-item" + (sel.value.has(d.id) ? " on" : "") }, [
+                h(UiInput, { type: "checkbox", checked: sel.value.has(d.id), onChange: (e) => toggle(d.id, e.target.checked) }),
+                h("span", { class: "picker-name" }, d.name),
+                h("span", { class: "picker-doms" }, doms.length ? doms.join("、") : "未归类"),
+              ]);
+            }),
+        h("div", { style: { marginTop: 10 } }, [
+          h("a", { class: "text-button", href: "#", onClick: (e) => { e.preventDefault(); p.go("deviceTemplates"); } }, "+ 新增设备模版"),
+        ]),
+      ]);
+  },
+};
+
+// ---- PageRow (1911-1924) ----
+const PageRow = {
+  props: {
+    p: { type: Object, default: () => ({}) },
+    notify: { type: Function, default: () => {} },
+    onChange: { type: Function, default: () => {} },
+  },
+  setup(p) {
+    const name = ref(p.p.name);
+    const description = ref(p.p.description || "");
+    const json_text = ref(p.p.json_text);
+    const editing = ref(false);
+    const { confirm, el: confirmEl } = useConfirm();
+    const save = async () => {
+      try { JSON.parse(json_text.value); } catch { p.notify("Cube JSON 不是合法 JSON"); return; }
+      await fetchDb(`/domain-pages/${p.p.id}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name: name.value, description: description.value, json_text: json_text.value }),
+      });
+      p.notify("页面已保存");
+      editing.value = false;
+      p.onChange();
+    };
+    const del = async () => {
+      if (!(await confirm("删除该页面?"))) return;
+      await fetchDb(`/domain-pages/${p.p.id}`, { method: "DELETE" });
+      p.onChange();
+    };
+    return () => [
+      h(confirmEl),
+      h("div", { class: "page-row" }, [
+        h("div", { class: "page-row-head" }, [
+          h("b", {}, name.value),
+          h("span", {}, editing.value
+            ? [h(Button, { onClick: save }, "保存"), " ", h(UiButton, { class: "text-button", onClick: () => (editing.value = false) }, "取消")]
+            : [h(UiButton, { class: "text-button", onClick: () => (editing.value = true) }, "编辑"), " ", h(UiButton, { class: "text-button", onClick: del }, "删除")]),
+        ]),
+        editing.value
+          ? h("div", { class: "form-grid" }, [
+              h("label", {}, ["页面名称", h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value) })]),
+              h("label", {}, ["说明", h(UiInput, { value: description.value, onChange: (e) => (description.value = e.target.value) })]),
+              h("label", { style: { gridColumn: "1 / -1" } }, [
+                "Cube JSON",
+                h(UiInput, { textarea: true, value: json_text.value, onChange: (e) => (json_text.value = e.target.value), rows: 10, style: { fontFamily: "monospace" } }),
+              ]),
+            ])
+          : null,
+      ]),
+    ];
+  },
+};
+
+// ---- OpsFlowsViewer (1926-1927) ----
+const OpsFlowsViewer = {
+  setup() {
+    const flows = ref([]);
+    onMounted(() => {
+      fetchDb("/ops-flows").then((d) => (flows.value = d || [])).catch(() => {});
+    });
+    return () =>
+      h(Panel, { title: "运维流程(本期为占位,后续实现)" }, () => [
+        h(UiTable, { class: "data-table" }, () => [
+          h("thead", {}, h("tr", {}, [h("th", {}, "流程名称"), h("th", {}, "内容")])),
+          h("tbody", {}, flows.value.length === 0
+            ? h("tr", {}, [h("td", { colSpan: 2, class: "muted" }, "暂无")])
+            : flows.value.map((f) => h("tr", { key: f.id }, [h("td", {}, f.name), h("td", { style: { maxWidth: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, f.content)]))),
+        ]),
+        h("p", { class: "muted" }, "运维流程功能尚未实现,当前仅保留占位记录。"),
+      ]);
+  },
+};
+
+</script>
+
+<template>
+  <component :is="confirmEl" />
+  <div class="system-settings-page">
+    <Panel title="领域配置">
+      <div class="settings-table-wrap">
+        <UiTable class="data-table company-table settings-domain-table">
+          <thead>
+            <tr><th>领域名称</th><th>使用范围</th><th>状态</th><th>操作</th></tr>
+          </thead>
+          <tbody>
+            <tr v-for="row in domains" :key="row.id">
+              <td><b>{{ row.name }}</b></td>
+              <td>领域方案、站点项目</td>
+              <td><Status :value="row.name === '其他' ? '系统预置' : '可用'" /></td>
+              <td>
+                <span v-if="row.name === '其他'" class="muted">不可删除</span>
+                <UiButton v-else class="text-button danger" @click="() => remove(row)">删除</UiButton>
+              </td>
+            </tr>
+          </tbody>
+        </UiTable>
+        <div v-if="domains.length === 0" class="empty-state">暂无领域,请点击右上角“新增领域”创建</div>
+      </div>
+    </Panel>
+  </div>
+</template>
diff --git a/src-vue/pages/Functions.vue b/src-vue/pages/Functions.vue
new file mode 100644 (file)
index 0000000..b4a4adc
--- /dev/null
@@ -0,0 +1,435 @@
+<script setup>
+import { ref, computed, watch } from "vue";
+import { Drawer, Button, Tooltip } from "ant-design-vue";
+import {
+  ApartmentOutlined,
+  CaretRightOutlined,
+  CaretDownOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+  EditOutlined,
+  EyeOutlined,
+} from "@ant-design/icons-vue";
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiActions from "../ui/UiActions.vue";
+import UiOverlay from "../ui/UiOverlay.vue";
+import {
+  APPLICATION_COLOR_SCHEMES,
+  buildSiteMenu,
+  menuSelectionSignature,
+  orderMenu,
+  selectedMenuSources,
+} from "../site-menu-model.js";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  leaves: { type: Array, default: () => [] },
+  onSiteChange: { type: Function, default: null },
+  notify: { type: Function, default: () => {} },
+  registerSave: { type: Function, default: null },
+});
+
+async function api(path, body, method = "POST") {
+  const res = await fetch(
+    `/api/db${path}`,
+    body === undefined
+      ? undefined
+      : { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
+  );
+  if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
+  return res.json();
+}
+
+const menu = ref(buildSiteMenu(props.site, props.leaves));
+const appName = ref(props.site?.app_name || props.site?.name || "");
+const appIcon = ref(props.site?.app_icon || null);
+const iconFilename = ref(props.site?.app_icon_filename || "");
+const scheme = ref(props.site?.app_color_scheme || "blue");
+const saving = ref(false);
+const savingRef = ref(false);
+const collapsed = ref(new Set());
+const previewCollapsed = ref(new Set());
+const previewOpen = ref(false);
+const previewPage = ref(null);
+const editDraft = ref(null);
+const restoreConfirm = ref(false);
+
+const signature = computed(() => menuSelectionSignature(props.site, props.leaves));
+watch(signature, (newSig, oldSig) => {
+  if (oldSig !== undefined && oldSig !== newSig) {
+    menu.value = buildSiteMenu(props.site, props.leaves, menu.value);
+    collapsed.value = new Set();
+  }
+});
+
+const sources = computed(() => selectedMenuSources(props.site, props.leaves));
+const color = computed(
+  () => APPLICATION_COLOR_SCHEMES.find((item) => item.key === scheme.value)?.color || "#009fe3"
+);
+const ordered = computed(() => orderMenu(menu.value));
+const children = (id) => menu.value.filter((item) => item.parent_id === id);
+const depth = (item) => {
+  let level = 0;
+  let parent = item.parent_id;
+  const seen = new Set();
+  while (parent != null && !seen.has(parent)) {
+    seen.add(parent);
+    level++;
+    parent = menu.value.find((other) => other.id === parent)?.parent_id ?? null;
+  }
+  return level;
+};
+const visible = (item, closed) => {
+  let parent = item.parent_id;
+  while (parent != null) {
+    if (closed.has(parent)) return false;
+    parent = menu.value.find((other) => other.id === parent)?.parent_id ?? null;
+  }
+  return true;
+};
+const toggle = (id, preview = false) => {
+  const target = preview ? previewCollapsed : collapsed;
+  const next = new Set(target.value);
+  if (next.has(id)) next.delete(id);
+  else next.add(id);
+  target.value = next;
+};
+const movable = (item) => item.page_type === "sc" || item.page_type === "cube";
+const move = (item, direction) => {
+  if (saving.value || !movable(item)) return;
+  const siblings = children(item.parent_id);
+  const index = siblings.findIndex((other) => other.id === item.id);
+  const target = siblings[index + direction];
+  if (!target) return;
+  const next = [...menu.value];
+  const a = next.findIndex((other) => other.id === item.id);
+  const b = next.findIndex((other) => other.id === target.id);
+  [next[a], next[b]] = [next[b], next[a]];
+  menu.value = orderMenu(next);
+};
+const readIcon = (file) => {
+  if (!file) return;
+  if (!["image/png", "image/jpeg", "image/svg+xml"].includes(file.type) || file.size > 100 * 1024) {
+    props.notify("Icon 支持 PNG、JPG、SVG,文件不大于 100 KB");
+    return;
+  }
+  const reader = new FileReader();
+  reader.onload = () => {
+    appIcon.value = reader.result;
+    iconFilename.value = file.name;
+  };
+  reader.onerror = () => props.notify("Icon 读取失败,请重新选择");
+  reader.readAsDataURL(file);
+};
+const save = async () => {
+  if (savingRef.value) throw new Error("菜单正在保存,请稍后重试");
+  if (editDraft.value) throw new Error("请先确定或取消当前菜单编辑");
+  if (!appName.value.trim()) throw new Error("请填写应用名称");
+  if (menu.value.some((item) => !item.label.trim())) throw new Error("请填写所有菜单名称");
+  const pageKeys = menu.value
+    .filter((item) => item.page_type)
+    .map((item) => `${item.page_type}:${item.ref_id}`);
+  if (pageKeys.length !== sources.value.length || sources.value.some((source) => !pageKeys.includes(source.key)))
+    throw new Error("页面选择已变化,请重新进入菜单配置");
+  savingRef.value = true;
+  saving.value = true;
+  try {
+    await api(
+      `/sites/${props.site.id}/menu`,
+      {
+        menu: orderMenu(menu.value),
+        application: {
+          app_name: appName.value.trim(),
+          app_icon: appIcon.value,
+          app_icon_filename: iconFilename.value,
+          app_color_scheme: scheme.value,
+        },
+      },
+      "PUT"
+    );
+    const saved = await api(`/sites/${props.site.id}`);
+    menu.value = buildSiteMenu(saved, props.leaves);
+    props.onSiteChange && props.onSiteChange(saved);
+  } finally {
+    savingRef.value = false;
+    saving.value = false;
+  }
+};
+
+watch(
+  [menu, appName, appIcon, iconFilename, scheme, saving, signature, () => editDraft.value],
+  (_, __, onInvalidate) => {
+    props.registerSave && props.registerSave(save);
+    onInvalidate(() => props.registerSave && props.registerSave(null));
+  },
+  { immediate: true }
+);
+
+const fixedGroup = (item) => ["group:sc", "group:cube"].includes(item.source_key);
+const kind = (item) =>
+  item.page_type === "sc"
+    ? "数字孪生场景"
+    : item.page_type === "cube"
+    ? "领域仪表板"
+    : item.page_type === "mu"
+    ? "标准功能页面"
+    : "菜单分组";
+const selectedPreview = computed(() => menu.value.find((item) => item.id === previewPage.value));
+const rowSiblings = (item) => (item.parent_id == null ? [] : children(item.parent_id));
+const rowIndex = (item) => rowSiblings(item).findIndex((other) => other.id === item.id);
+
+function collapseAll() {
+  collapsed.value = collapsed.value.size
+    ? new Set()
+    : new Set(menu.value.filter((item) => !item.page_type).map((item) => item.id));
+}
+function openPreview() {
+  previewOpen.value = true;
+  previewPage.value = null;
+  previewCollapsed.value = new Set();
+}
+function confirmEditDraft() {
+  if (!editDraft.value?.label.trim()) {
+    props.notify("请填写菜单名称");
+    return;
+  }
+  const id = editDraft.value.id;
+  const label = editDraft.value.label.trim();
+  menu.value = menu.value.map((item) => (item.id === id ? { ...item, label } : item));
+  editDraft.value = null;
+}
+function restoreDefault() {
+  menu.value = buildSiteMenu(props.site, props.leaves, []);
+  collapsed.value = new Set();
+  restoreConfirm.value = false;
+}
+</script>
+
+<template>
+  <div class="station-menu-configuration station-application-settings">
+    <section class="station-application-basic">
+      <header><h2>基本信息</h2></header>
+      <div class="station-application-fields">
+        <label class="station-application-name">
+          <span><i>*</i> 应用名称</span>
+          <UiInput
+            aria-label="应用名称"
+            :maxLength="100"
+            :value="appName"
+            :disabled="saving"
+            placeholder="请输入应用名称"
+            @change="(e) => (appName = e.target.value)"
+          />
+        </label>
+        <div class="station-application-icon">
+          <span>Icon</span>
+          <div class="station-icon-control">
+            <div class="station-icon-image">
+              <img v-if="appIcon" :src="appIcon" alt="应用 Icon" />
+              <ApartmentOutlined v-else />
+            </div>
+            <label :class="saving ? 'station-icon-upload disabled' : 'station-icon-upload'">
+              <span>{{ appIcon ? "更换 Icon" : "上传 Icon" }}</span>
+              <input
+                aria-label="上传应用 Icon"
+                type="file"
+                hidden
+                :disabled="saving"
+                accept="image/png,image/jpeg,image/svg+xml"
+                @change="(e) => { readIcon(e.target.files?.[0]); e.target.value = ''; }"
+              />
+            </label>
+            <span class="station-icon-file" :title="iconFilename">{{ iconFilename || "PNG / JPG / SVG,≤100 KB" }}</span>
+            <UiButton v-if="appIcon" className="link" :disabled="saving" @click="() => { appIcon = null; iconFilename = ''; }">清除</UiButton>
+          </div>
+        </div>
+        <label class="station-application-scheme">
+          <span>色系</span>
+          <div>
+            <i :style="{ background: color }" />
+            <UiSelect
+              aria-label="应用色系"
+              :value="scheme"
+              :disabled="saving"
+              @change="(e) => (scheme = e.target.value)"
+            >
+              <option v-for="item in APPLICATION_COLOR_SCHEMES" :key="item.key" :value="item.key">{{ item.label }}</option>
+            </UiSelect>
+          </div>
+        </label>
+      </div>
+    </section>
+    <section class="station-application-menu">
+      <header>
+        <h2>菜单列表 <small>{{ sources.length }} 个页面</small></h2>
+        <UiActions className="station-application-menu-actions">
+          <Button :disabled="saving" @click="collapseAll">{{ collapsed.size ? "展开全部" : "收起全部" }}</Button>
+          <Button :disabled="saving" @click="() => (restoreConfirm = true)">恢复默认</Button>
+          <Button type="primary" @click="openPreview">
+            <template #icon><EyeOutlined /></template>
+            预览菜单
+          </Button>
+        </UiActions>
+      </header>
+      <div class="station-application-menu-scroll">
+        <UiTable className="data-table company-table station-application-menu-table">
+          <thead>
+            <tr><th>菜单名称</th><th>关联页面</th><th>页面类型</th><th>操作</th></tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="item in ordered.filter((i) => visible(i, collapsed))"
+              :key="item.id"
+              :class="!item.page_type ? 'station-menu-group-row' : ''"
+            >
+              <td>
+                <div class="station-menu-name" :style="{ paddingLeft: depth(item) * 28 + 'px' }">
+                  <template v-if="!item.page_type">
+                    <UiButton
+                      className="station-menu-disclosure"
+                      :aria-label="`${collapsed.has(item.id) ? '展开' : '收起'}菜单${item.label}`"
+                      :aria-expanded="!collapsed.has(item.id)"
+                      @click="() => toggle(item.id)"
+                    >
+                      <CaretRightOutlined v-if="collapsed.has(item.id)" />
+                      <CaretDownOutlined v-else />
+                    </UiButton>
+                  </template>
+                  <span v-else class="station-menu-child-guide" />
+                  <span>{{ item.label }}</span>
+                  <small v-if="!item.page_type">{{ children(item.id).length }} 项</small>
+                </div>
+              </td>
+              <td>
+                <span :class="!item.page_type ? 'station-menu-association muted' : 'station-menu-association'">{{ !item.page_type ? "—" : item.source_name }}</span>
+              </td>
+              <td>{{ kind(item) }}</td>
+              <td>
+                <div class="station-menu-row-actions">
+                  <Tooltip :title="!movable(item) ? '保持原有菜单结构与顺序' : '上移'">
+                    <Button
+                      type="text"
+                      :aria-label="`上移菜单${item.label}`"
+                      :disabled="saving || !movable(item) || rowIndex(item) <= 0"
+                      @click="() => move(item, -1)"
+                    >
+                      <template #icon><ArrowUpOutlined /></template>
+                    </Button>
+                  </Tooltip>
+                  <Tooltip :title="!movable(item) ? '保持原有菜单结构与顺序' : '下移'">
+                    <Button
+                      type="text"
+                      :aria-label="`下移菜单${item.label}`"
+                      :disabled="saving || !movable(item) || rowIndex(item) >= rowSiblings(item).length - 1"
+                      @click="() => move(item, 1)"
+                    >
+                      <template #icon><ArrowDownOutlined /></template>
+                    </Button>
+                  </Tooltip>
+                  <Tooltip :title="fixedGroup(item) ? '领域菜单名称固定' : '编辑'">
+                    <Button
+                      type="text"
+                      :aria-label="`编辑菜单${item.label}`"
+                      :disabled="saving || fixedGroup(item)"
+                      @click="() => (editDraft = { ...item })"
+                    >
+                      <template #icon><EditOutlined /></template>
+                    </Button>
+                  </Tooltip>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </UiTable>
+      </div>
+      <footer class="station-application-menu-note">
+        <span>数字孪生、领域监控固定在前;标准功能保留原有结构与顺序。</span>
+        <span>{{ saving ? "正在保存…" : (!props.site.mu?.length && !props.site.page_config?.standard_pages_initialized ? "标准功能默认全选,可在第四步调整" : "增减页面请返回第四步“页面配置”") }}</span>
+      </footer>
+    </section>
+    <Drawer
+      className="station-info-drawer station-menu-edit-drawer"
+      :width="560"
+      title="编辑菜单"
+      :open="!!editDraft"
+      @close="() => (editDraft = null)"
+    >
+      <div v-if="editDraft" class="station-menu-edit-fields">
+        <label>
+          <span><i>*</i> 菜单名称</span>
+          <UiInput
+            aria-label="编辑菜单名称"
+            :value="editDraft.label"
+            :maxLength="50"
+            @change="(e) => (editDraft = { ...editDraft, label: e.target.value })"
+          />
+        </label>
+        <label>
+          <span>上级菜单</span>
+          <UiInput readonly :value="menu.find((item) => item.id === editDraft.parent_id)?.label || '一级菜单'" />
+        </label>
+        <label>
+          <span>关联页面</span>
+          <UiInput readonly :value="editDraft.page_type ? editDraft.source_name : '菜单分组'" />
+        </label>
+      </div>
+      <template #footer>
+        <UiActions className="station-drawer-actions">
+          <Button @click="() => (editDraft = null)">取消</Button>
+          <Button type="primary" @click="confirmEditDraft">确定</Button>
+        </UiActions>
+      </template>
+    </Drawer>
+    <Drawer
+      className="station-info-drawer station-application-preview-drawer"
+      :width="460"
+      title="菜单预览"
+      :open="previewOpen"
+      @close="() => (previewOpen = false)"
+    >
+      <div class="station-application-preview" :style="{ '--application-color': color }">
+        <header>
+          <img v-if="appIcon" :src="appIcon" alt="应用 Icon" />
+          <ApartmentOutlined v-else />
+          <strong>{{ appName || "未命名应用" }}</strong>
+        </header>
+        <nav>
+          <div
+            v-for="item in ordered.filter((i) => visible(i, previewCollapsed))"
+            :key="item.id"
+            :style="{ paddingLeft: 18 + depth(item) * 26 + 'px' }"
+          >
+            <UiButton
+              :class="!item.page_type ? 'group' : previewPage === item.id ? 'page active' : 'page'"
+              @click="() => (item.page_type ? (previewPage = item.id) : toggle(item.id, true))"
+            >
+              <span>{{ item.label }}</span>
+              <CaretRightOutlined v-if="!item.page_type && previewCollapsed.has(item.id)" />
+              <CaretDownOutlined v-else-if="!item.page_type" />
+            </UiButton>
+          </div>
+        </nav>
+        <div v-if="selectedPreview" class="station-menu-preview-current">
+          <span>{{ kind(selectedPreview) }}</span>
+          <b>{{ selectedPreview.source_name }}</b>
+          <small>菜单预览仅展示入口,不打开实际页面。</small>
+        </div>
+      </div>
+    </Drawer>
+    <UiOverlay v-if="restoreConfirm" className="modal-backdrop">
+      <div class="modal station-replace-confirm">
+        <header><h2>恢复默认菜单</h2><UiButton @click="() => (restoreConfirm = false)">×</UiButton></header>
+        <div class="modal-body">
+          <p>按第四步的页面选择恢复菜单名称和排序。应用名称、Icon 与色系保持不变,点击“保存草稿”后生效。</p>
+        </div>
+        <footer>
+          <Button @click="() => (restoreConfirm = false)">取消</Button>
+          <Button type="primary" @click="restoreDefault">恢复默认</Button>
+        </footer>
+      </div>
+    </UiOverlay>
+  </div>
+</template>
diff --git a/src-vue/pages/Generate.vue b/src-vue/pages/Generate.vue
new file mode 100644 (file)
index 0000000..b0795d6
--- /dev/null
@@ -0,0 +1,60 @@
+<script setup>
+import { computed } from "vue";
+import { Button } from "ant-design-vue";
+import { DownloadOutlined } from "@ant-design/icons-vue";
+import Panel from "../components/Panel.vue";
+import UiButton from "../ui/UiButton.vue";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  onSiteChange: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+  viewPrompt: { type: Function, default: () => {} },
+  viewSitePrompt: { type: Function, default: () => {} },
+});
+
+// NOTE (faithful port): the React source defines running/tasks/progress/zipUrl/
+// log state plus a `start()` streaming generator, but the returned JSX only
+// renders the confirmation grid below — the generate trigger is not wired in the
+// source, so only the confirmation DOM is reproduced here.
+
+const scope = computed(() => {
+  const s = props.site;
+  if (!s) return [];
+  const selectedCubes = (s.cubes || []).filter(
+    (cube) => !s.page_config?.dashboard_ids || s.page_config.dashboard_ids.includes(cube.id)
+  );
+  return [
+    ["站点", s.name || "未命名站点", s.domain],
+    [
+      "Asset 资产",
+      `${s.assets.filter((a) => a.type).length} 类 / ${s.assets.reduce((n, a) => n + Number(a.count || 0), 0)} 个设备`,
+      `使用当前站点已保存的资产组结构`,
+    ],
+    ["标准页面", `${s.mu.length} 个标准页面`, `使用第四步页面选择与第五步菜单配置`],
+    ["Cube 页面", `${selectedCubes.length} 个扩展页面`, `生成 ${selectedCubes.length} 个独立 JSON`],
+  ];
+});
+</script>
+
+<template>
+  <Panel v-if="!site"><div class="empty-state">加载中…</div></Panel>
+  <div v-else class="generate-layout">
+    <Panel title="生成范围确认">
+      <div class="confirm-grid">
+        <div v-for="x in scope" :key="x[0]">
+          <span>{{ x[0] }}</span>
+          <b>{{ x[1] }}</b>
+          <small>{{ x[2] }}</small>
+          <div style="display: flex; gap: 6px">
+            <UiButton>返回修改</UiButton>
+            <UiButton class="text-button"><DownloadOutlined /> 下载</UiButton>
+          </div>
+        </div>
+      </div>
+      <div style="margin-top: 16px; text-align: right">
+        <Button type="primary" class="button"><DownloadOutlined /> 下载全部</Button>
+      </div>
+    </Panel>
+  </div>
+</template>
diff --git a/src-vue/pages/ManualAssetConfiguration.vue b/src-vue/pages/ManualAssetConfiguration.vue
new file mode 100644 (file)
index 0000000..a282eff
--- /dev/null
@@ -0,0 +1,1054 @@
+<script setup>
+import { ref, computed, watch, onMounted, onUnmounted, h, defineComponent } from "vue";
+import { Drawer, Dropdown, QRCode, Button as AntButton } from "ant-design-vue";
+import {
+  CopyOutlined, SnippetsOutlined, ControlOutlined, DeleteOutlined, PlusSquareOutlined,
+  PlusOutlined, BlockOutlined, ApartmentOutlined, BarsOutlined, ZoomOutOutlined,
+  ZoomInOutlined, AimOutlined, SearchOutlined, EditOutlined, LinkOutlined, UploadOutlined,
+} from "@ant-design/icons-vue";
+
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
+import UiActions from "../ui/UiActions.vue";
+import UiOverlay from "../ui/UiOverlay.vue";
+import Field from "../components/Field.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+
+import { fetchDb } from "../lib/api.js";
+
+const props = defineProps({
+  site: { type: Object, required: true },
+  notify: { type: Function, default: () => {} },
+  onSiteChange: { type: Function, default: null },
+  registerSave: { type: Function, default: null },
+});
+const notify = (m) => (props.notify ? props.notify(m) : null);
+
+// ---------------------------------------------------------------------------
+// helpers (ported from app/page.tsx 1106-1133, 1179-1188, 127)
+// ---------------------------------------------------------------------------
+function newAssetNodeId() {
+  return "node-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
+}
+function cloneAssetNode(node) {
+  return {
+    ...node,
+    id: newAssetNodeId(),
+    properties: node.properties.map((item) => ({ ...item })),
+    bindings: node.bindings.map((item) => ({ ...item })),
+    children: node.children.map(cloneAssetNode),
+  };
+}
+function findAssetNode(nodes, id) {
+  for (const node of nodes) {
+    if (node.id === id) return node;
+    const child = findAssetNode(node.children, id);
+    if (child) return child;
+  }
+  return null;
+}
+function updateAssetNode(nodes, id, next) {
+  return nodes.map((node) =>
+    node.id === id ? next : { ...node, children: updateAssetNode(node.children, id, next) }
+  );
+}
+function appendAssetNode(nodes, parentId, node) {
+  if (!parentId) return [...nodes, node];
+  return nodes.map((item) =>
+    item.id === parentId
+      ? { ...item, children: [...item.children, node] }
+      : { ...item, children: appendAssetNode(item.children, parentId, node) }
+  );
+}
+function removeAssetNode(nodes, id) {
+  return nodes
+    .filter((node) => node.id !== id)
+    .map((node) => ({ ...node, children: removeAssetNode(node.children, id) }));
+}
+function findAssetParentId(nodes, id, parentId = null) {
+  for (const node of nodes) {
+    if (node.id === id) return parentId;
+    const found = findAssetParentId(node.children, id, node.id);
+    if (found !== null) return found;
+  }
+  return null;
+}
+function flattenAssetNodes(nodes, depth = 0) {
+  return nodes.flatMap((node) => [{ node, depth }, ...flattenAssetNodes(node.children, depth + 1)]);
+}
+const assetPropertyFields = [
+  "型号", "价值", "地址", "安装位置", "负责人", "品牌", "供应商", "经销商",
+  "出厂日期", "投产日期", "质保截止日期", "NFC ID", "备注", "设备运行状态计算阈值",
+];
+function assetPropertyValue(node, name) {
+  return node.properties.find((item) => item.name === name)?.value || "";
+}
+function setAssetProperty(node, name, value) {
+  const exists = node.properties.some((item) => item.name === name);
+  return {
+    ...node,
+    properties: exists
+      ? node.properties.map((item) => (item.name === name ? { ...item, value } : item))
+      : [...node.properties, { name, value, unit: "" }],
+  };
+}
+function formatInstances(rule, count) {
+  if (!rule || count <= 0) return "";
+  const pad = Math.max(2, String(count).length);
+  const parts = [];
+  for (let i = 1; i <= count; i++) {
+    const num = String(i).padStart(pad, "0");
+    if (rule.includes("#")) {
+      parts.push(rule.replace(/#+/g, (m) => String(i).padStart(Math.max(pad, m.length), "0")));
+    } else {
+      const sep = rule.endsWith("-") ? "" : "-";
+      parts.push(`${rule}${sep}${num}`);
+    }
+  }
+  return parts.join("、");
+}
+
+// ---------------------------------------------------------------------------
+// load / save
+// ---------------------------------------------------------------------------
+const group = ref(null);
+const deviceTemplates = ref([]);
+const templateNames = ref({});
+const loading = ref(true);
+const loadError = ref("");
+
+const seedChildren = () =>
+  props.site.assets
+    .filter((item) => item.type)
+    .map((item) => {
+      const count = Math.max(0, Number(item.count) || 0);
+      return {
+        id: newAssetNodeId(),
+        name: item.type,
+        node_type: "group",
+        template_name: item.type,
+        description: `${item.type}设备集合`,
+        properties: [],
+        bindings: [],
+        children: Array.from({ length: count }, (_, index) => ({
+          id: newAssetNodeId(),
+          name:
+            formatInstances(item.rule, count).split("、")[index] ||
+            `${item.type}-${String(index + 1).padStart(2, "0")}`,
+          node_type: "device",
+          template_name: item.type,
+          description: "",
+          properties: [],
+          bindings: [],
+          children: [],
+        })),
+      };
+    });
+const seedStructure = () => [
+  {
+    id: newAssetNodeId(),
+    name: props.site.name || "未命名站点",
+    node_type: "group",
+    template_name: "站点资产根节点",
+    description: "当前站点的资产结构根节点",
+    properties: [],
+    bindings: [],
+    children: seedChildren(),
+  },
+];
+
+const loadGroup = async () => {
+  if (!props.site?.id) return;
+  loading.value = true;
+  loadError.value = "";
+  try {
+    const [loadedGroups, allDevices] = await Promise.all([
+      fetchDb(`/sites/${props.site.id}/asset-groups`),
+      fetchDb("/device-templates"),
+    ]);
+    templateNames.value = Object.fromEntries(allDevices.map((device) => [String(device.id), device.name]));
+    let rows = loadedGroups || [];
+    if (rows.length === 0) {
+      const created = await fetchDb(`/sites/${props.site.id}/asset-groups`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          name: `${props.site.name || "未命名站点"}资产组`,
+          category: props.site.domain || "站点资产",
+          template_name: "通用资产模板",
+          structure: seedStructure(),
+        }),
+      });
+      rows = [created];
+    }
+    const current = rows[0];
+    const g = {
+      ...current,
+      name: current.name?.trim() || `${props.site.name || "未命名站点"}资产组`,
+      structure: current.structure && current.structure.length ? current.structure : seedStructure(),
+    };
+    group.value = g;
+    draft.value = { ...g, structure: normalizeLegacy(g.structure) };
+    selectedId.value = draft.value.structure[0]?.id || null;
+  } catch (e) {
+    const message = "加载资产组失败:" + (e?.message || e);
+    loadError.value = message;
+    notify(message);
+  } finally {
+    loading.value = false;
+  }
+};
+
+const normalizeLegacy = (nodes) =>
+  nodes.map((node) => ({
+    ...node,
+    name: templateNames.value[node.name] || node.name,
+    template_name: templateNames.value[node.template_name] || node.template_name,
+    children: normalizeLegacy(node.children),
+  }));
+
+const saveGroup = async (g) => {
+  const saved = await fetchDb(`/sites/${props.site.id}/asset-groups/${g.id}`, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(g),
+  });
+  group.value = saved;
+  if (props.onSiteChange) props.onSiteChange(await fetchDb(`/sites/${props.site.id}`));
+  notify("资产组已保存");
+  return saved;
+};
+
+onMounted(loadGroup);
+watch(
+  () => props.site?.id,
+  () => {
+    group.value = null;
+    loadGroup();
+  }
+);
+
+// ---------------------------------------------------------------------------
+// editor state (folded from React ManualAssetEditor)
+// ---------------------------------------------------------------------------
+const draft = ref(null);
+const selectedId = ref(null);
+const nodeOpen = ref(false);
+const nodeTab = ref("basic");
+const nodeDraft = ref(null);
+const batchOpen = ref(false);
+const batchTab = ref("online");
+const batchRows = ref([{ template_name: "进水提升泵", count: 2, rule: "beng-ts-##" }]);
+const clipboard = ref(null);
+const search = ref("");
+const viewMode = ref("tree");
+const zoom = ref(1);
+const pan = ref({ x: 0, y: 0 });
+const dragging = ref(false);
+const saving = ref(false);
+const templateCategory = ref("site");
+const creatingNodeId = ref(null);
+const bindingComponent = ref("HealthStatus");
+const bindingImportOpen = ref(false);
+
+const selected = computed(() =>
+  selectedId.value ? findAssetNode(draft.value ? draft.value.structure : [], selectedId.value) : null
+);
+
+const assetTemplates = computed(() =>
+  Array.from(
+    new Set([
+      ...deviceTemplates.value,
+      ...flattenAssetNodes(draft.value ? draft.value.structure : [])
+        .map(({ node }) => node.template_name)
+        .filter(Boolean),
+      "不绑定设备的模板",
+    ])
+  )
+);
+const templateList = computed(() =>
+  templateCategory.value === "site" ? assetTemplates.value : ["不绑定设备的模板"]
+);
+const visibleRows = computed(() =>
+  flattenAssetNodes(draft.value ? draft.value.structure : [])
+    .filter(
+      ({ node }) =>
+        !search.value ||
+        node.name.toLowerCase().includes(search.value.toLowerCase()) ||
+        node.template_name.toLowerCase().includes(search.value.toLowerCase())
+    )
+);
+const bindingComponents = computed(() =>
+  Array.from(
+    new Set([
+      "HealthStatus",
+      "Motor DE H",
+      "Motor NDE H",
+      ...(nodeDraft.value?.bindings.map((item) => item.source).filter(Boolean) || []),
+    ])
+  )
+);
+
+// ---- node / draft helpers ----
+const patchNode = (patch) => {
+  if (nodeDraft.value) nodeDraft.value = { ...nodeDraft.value, ...patch };
+};
+const applyAssetProperty = (name, value) => {
+  if (nodeDraft.value) nodeDraft.value = setAssetProperty(nodeDraft.value, name, value);
+};
+const openNode = (node) => {
+  creatingNodeId.value = null;
+  selectedId.value = node.id;
+  nodeDraft.value = {
+    ...node,
+    alias: node.alias || `${node.name}-alias`,
+    asset_type: node.asset_type || (node.node_type === "device" ? "Machine" : "Group"),
+    properties: node.properties.map((item) => ({ ...item })),
+    bindings: node.bindings.map((item) => ({ ...item })),
+    children: node.children,
+  };
+  bindingComponent.value = node.bindings[0]?.source || "HealthStatus";
+  nodeTab.value = "basic";
+  nodeOpen.value = true;
+};
+const addNode = (parent, templateName) => {
+  const p = parent ?? selected.value;
+  const tpl = templateName ?? (draft.value ? draft.value.template_name : "");
+  const node = {
+    id: newAssetNodeId(),
+    name: "新建资产节点",
+    alias: "new-asset-alias",
+    asset_type: "Machine",
+    node_type: "device",
+    template_name: tpl,
+    description: "",
+    properties: [],
+    bindings: [],
+    children: [],
+  };
+  draft.value = {
+    ...draft.value,
+    structure: appendAssetNode(draft.value.structure, p?.id || null, node),
+  };
+  creatingNodeId.value = node.id;
+  selectedId.value = node.id;
+  nodeDraft.value = node;
+  bindingComponent.value = "HealthStatus";
+  nodeTab.value = "basic";
+  nodeOpen.value = true;
+};
+const addSibling = () => {
+  const parentId = selectedId.value ? findAssetParentId(draft.value.structure, selectedId.value) : null;
+  const node = {
+    id: newAssetNodeId(),
+    name: "新建同级节点",
+    alias: "new-peer-alias",
+    asset_type: "Machine",
+    node_type: "device",
+    template_name: draft.value.template_name,
+    description: "",
+    properties: [],
+    bindings: [],
+    children: [],
+  };
+  draft.value = {
+    ...draft.value,
+    structure: appendAssetNode(draft.value.structure, parentId, node),
+  };
+  creatingNodeId.value = node.id;
+  selectedId.value = node.id;
+  nodeDraft.value = node;
+  bindingComponent.value = "HealthStatus";
+  nodeTab.value = "basic";
+  nodeOpen.value = true;
+};
+const pasteNode = () => {
+  if (!clipboard.value) {
+    notify("请先复制一个节点");
+    return;
+  }
+  const node = cloneAssetNode(clipboard.value);
+  draft.value = {
+    ...draft.value,
+    structure: appendAssetNode(draft.value.structure, selectedId.value, node),
+  };
+  selectedId.value = node.id;
+  notify("节点已粘贴");
+};
+const deleteNode = () => {
+  if (!selectedId.value) {
+    notify("请先选择节点");
+    return;
+  }
+  draft.value = {
+    ...draft.value,
+    structure: removeAssetNode(draft.value.structure, selectedId.value),
+  };
+  selectedId.value = null;
+  notify("节点已移除,保存后生效");
+};
+const saveNode = () => {
+  if (!nodeDraft.value) return;
+  if (
+    !nodeDraft.value.name.trim() ||
+    !nodeDraft.value.alias?.trim() ||
+    !nodeDraft.value.template_name.trim() ||
+    !nodeDraft.value.asset_type?.trim()
+  ) {
+    notify("请填写名称、编号/别名、模板和类型");
+    return;
+  }
+  draft.value = {
+    ...draft.value,
+    structure: updateAssetNode(draft.value.structure, nodeDraft.value.id, nodeDraft.value),
+  };
+  creatingNodeId.value = null;
+  nodeOpen.value = false;
+  notify("节点信息已更新");
+};
+const cancelNode = () => {
+  if (creatingNodeId.value) {
+    draft.value = {
+      ...draft.value,
+      structure: removeAssetNode(draft.value.structure, creatingNodeId.value),
+    };
+    selectedId.value = null;
+  }
+  creatingNodeId.value = null;
+  nodeOpen.value = false;
+  nodeDraft.value = null;
+};
+const readNodeImage = (file) => {
+  if (file.size > 2 * 1024 * 1024) {
+    notify("图片不能超过 2 MB");
+    return;
+  }
+  const reader = new FileReader();
+  reader.onload = () => {
+    if (nodeDraft.value) nodeDraft.value = { ...nodeDraft.value, image_data: String(reader.result || "") };
+  };
+  reader.readAsDataURL(file);
+};
+const onNodeImageChange = (e) => {
+  const file = e.target.files?.[0];
+  if (file) readNodeImage(file);
+};
+
+// ---- batch ----
+const addBatchRow = () => batchRows.value = [...batchRows.value, { template_name: "", count: 1, rule: "device-##" }];
+const updateBatchRow = (index, patch) =>
+  (batchRows.value = batchRows.value.map((item, i) => (i === index ? { ...item, ...patch } : item)));
+const removeBatchRow = (index) => (batchRows.value = batchRows.value.filter((_, i) => i !== index));
+const createBatch = () => {
+  let structure = draft.value.structure;
+  for (const row of batchRows.value.filter((item) => item.template_name && item.count > 0)) {
+    const parent = {
+      id: newAssetNodeId(),
+      name: row.template_name,
+      node_type: "group",
+      template_name: row.template_name,
+      description: "批量创建",
+      properties: [],
+      bindings: [],
+      children: Array.from({ length: row.count }, (_, index) => ({
+        id: newAssetNodeId(),
+        name: row.rule.includes("#")
+          ? row.rule.replace(/#+/, (m) => String(index + 1).padStart(m.length, "0"))
+          : `${row.rule}${String(index + 1).padStart(2, "0")}`,
+        node_type: "device",
+        template_name: row.template_name,
+        description: "",
+        properties: [],
+        bindings: [],
+        children: [],
+      })),
+    };
+    structure = appendAssetNode(structure, selectedId.value, parent);
+  }
+  draft.value = { ...draft.value, structure };
+  batchOpen.value = false;
+  notify("批量节点已加入资产树");
+};
+const onBatchFileChange = (e) => {
+  const file = e.target.files?.[0];
+  if (file) importBatchFile(file);
+};
+const importBatchFile = async (file) => {
+  try {
+    const XLSX = await import("xlsx");
+    const wb = XLSX.read(await file.arrayBuffer());
+    const sheet = wb.Sheets[wb.SheetNames[0]];
+    const data = XLSX.utils.sheet_to_json(sheet);
+    const rows = data
+      .map((item) => ({
+        template_name: String(item["设备类型"] || item["资产模板"] || item["名称"] || ""),
+        count: Number(item["数量"] || 1),
+        rule: String(item["命名规则"] || "device-##"),
+      }))
+      .filter((item) => item.template_name);
+    if (!rows.length) throw new Error("未找到“设备类型/资产模板、数量、命名规则”列");
+    batchRows.value = rows;
+    batchTab.value = "online";
+    notify(`已读取 ${rows.length} 行配置`);
+  } catch (e) {
+    notify("读取文件失败:" + (e?.message || e));
+  }
+};
+
+// ---- bindings ----
+const copySelected = (deep) => {
+  if (!selected.value) {
+    notify("请先选择节点");
+    return;
+  }
+  clipboard.value = deep ? selected.value : { ...selected.value, children: [] };
+  notify(deep ? "已深度复制节点及其下级" : "已复制节点");
+};
+const editSelected = (tab = "basic") => {
+  if (!selected.value) {
+    notify("请先选择节点");
+    return;
+  }
+  openNode(selected.value);
+  nodeTab.value = tab;
+};
+const bindTemplate = (templateName) => {
+  if (!selected.value) {
+    notify("请先选择节点");
+    return;
+  }
+  const next = { ...selected.value, template_name: templateName };
+  draft.value = {
+    ...draft.value,
+    structure: updateAssetNode(draft.value.structure, selected.value.id, next),
+  };
+  notify(`已应用${templateName}`);
+};
+const bindEdgeComponent = (component) => {
+  if (!nodeDraft.value) return;
+  const exists = nodeDraft.value.bindings.some((item) => item.source === component);
+  const bindings = exists
+    ? nodeDraft.value.bindings.map((item) =>
+        item.source === component
+          ? {
+              ...item,
+              collect_type: item.collect_type || "IoT Edge",
+              group: item.group || "Default",
+              device_name: item.device_name || `${nodeDraft.value.name}-device`,
+            }
+          : item
+      )
+    : [
+        ...nodeDraft.value.bindings,
+        {
+          source: component,
+          name: component,
+          point: "",
+          collect_type: "IoT Edge",
+          group: "Default",
+          device_name: `${nodeDraft.value.name}-device`,
+          unit: "",
+          precision: "2",
+          mapping: "",
+          description: "",
+        },
+      ];
+  nodeDraft.value = { ...nodeDraft.value, bindings };
+  bindingComponent.value = component;
+  notify(`已绑定边缘组件 ${component}`);
+};
+const updateBinding = (index, patch) => {
+  if (!nodeDraft.value) return;
+  const targetIndexes = nodeDraft.value.bindings
+    .map((item, i) => (item.source === bindingComponent.value ? i : -1))
+    .filter((i) => i >= 0);
+  const target = targetIndexes[index];
+  if (target === undefined) return;
+  nodeDraft.value = {
+    ...nodeDraft.value,
+    bindings: nodeDraft.value.bindings.map((item, i) => (i === target ? { ...item, ...patch } : item)),
+  };
+};
+const removeBinding = (index) => {
+  if (!nodeDraft.value) return;
+  const targetIndexes = nodeDraft.value.bindings
+    .map((item, i) => (item.source === bindingComponent.value ? i : -1))
+    .filter((i) => i >= 0);
+  const target = targetIndexes[index];
+  nodeDraft.value = {
+    ...nodeDraft.value,
+    bindings: nodeDraft.value.bindings.filter((_, i) => i !== target),
+  };
+};
+const onBindingFileChange = (e) => {
+  const file = e.target.files?.[0];
+  if (file) importBindings(file);
+};
+const importBindings = async (file) => {
+  if (!nodeDraft.value) return;
+  try {
+    const XLSX = await import("xlsx");
+    const wb = XLSX.read(await file.arrayBuffer());
+    const sheet = wb.Sheets[wb.SheetNames[0]];
+    const data = XLSX.utils.sheet_to_json(sheet);
+    const rows = data
+      .map((item) => ({
+        source: bindingComponent.value,
+        name: String(item["名称"] || item["参数名称"] || ""),
+        point: String(item["采集点"] || item["点位"] || ""),
+        unit: String(item["单位"] || ""),
+        precision: String(item["精度"] ?? "2"),
+        mapping: String(item["映射"] || ""),
+        description: String(item["描述"] || ""),
+      }))
+      .filter((item) => item.name || item.point);
+    if (!rows.length) throw new Error("未找到名称或采集点数据");
+    nodeDraft.value = {
+      ...nodeDraft.value,
+      bindings: [
+        ...nodeDraft.value.bindings.filter((item) => item.source !== bindingComponent.value),
+        ...rows,
+      ],
+    };
+    bindingImportOpen.value = false;
+    notify(`已导入 ${rows.length} 个点位参数`);
+  } catch (e) {
+    notify("导入失败:" + (e?.message || e));
+  }
+};
+
+// ---- view / zoom ----
+const setViewMode = (m) => (viewMode.value = m);
+const setTemplateCategory = (c) => (templateCategory.value = c);
+const setBatchTab = (t) => (batchTab.value = t);
+const setNodeTab = (t) => (nodeTab.value = t);
+const selectBindingComponent = (c) => (bindingComponent.value = c);
+const changeZoom = (fn) => (zoom.value = fn(zoom.value));
+const fitCanvas = () => {
+  zoom.value = 1;
+  pan.value = { x: 0, y: 0 };
+};
+const onCanvasPointerDown = (e) => {
+  if (e.target.closest && e.target.closest("button,input,[role='button']")) return;
+  dragging.value = true;
+  e.currentTarget.setPointerCapture(e.pointerId);
+};
+const onCanvasPointerMove = (e) => {
+  if (dragging.value) pan.value = { x: pan.value.x + e.movementX, y: pan.value.y + e.movementY };
+};
+const onCanvasPointerUp = (e) => {
+  dragging.value = false;
+  e.currentTarget.releasePointerCapture(e.pointerId);
+};
+
+// ---- save ----
+const saveAll = async () => {
+  if (nodeOpen.value || batchOpen.value) throw new Error("请先确认或取消正在编辑的节点/批量配置");
+  if (saving.value) throw new Error("资产正在保存");
+  if (JSON.stringify(draft.value) === JSON.stringify(group.value)) return;
+  saving.value = true;
+  try {
+    const saved = await saveGroup(draft.value);
+    draft.value = saved;
+  } finally {
+    saving.value = false;
+  }
+};
+const onSaveClick = async () => {
+  try {
+    await saveAll();
+    notify("资产组已保存");
+  } catch (e) {
+    notify("保存失败:" + (e?.message || e));
+  }
+};
+
+watch([draft, group, saving, nodeOpen, batchOpen], () => {
+  if (props.registerSave) props.registerSave(saveAll);
+});
+onUnmounted(() => {
+  if (props.registerSave) props.registerSave(null);
+});
+
+// ---------------------------------------------------------------------------
+// recursive tree branch (port of React AssetTreeBranch, 1269-1273)
+// ---------------------------------------------------------------------------
+const AssetTreeBranch = defineComponent({
+  name: "AssetTreeBranch",
+  props: {
+    node: { type: Object, required: true },
+    selectedId: { type: String, default: null },
+    search: { type: String, default: "" },
+    onSelect: { type: Function, default: null },
+    onOpen: { type: Function, default: null },
+    onAddChild: { type: Function, default: null },
+  },
+  setup(p) {
+    const expanded = ref(true);
+    const matched = computed(
+      () =>
+        !!p.search &&
+        (p.node.name.toLowerCase().includes(p.search.toLowerCase()) ||
+          p.node.template_name.toLowerCase().includes(p.search.toLowerCase()))
+    );
+    const cardClass = computed(() =>
+      [
+        "asset-node-card",
+        p.selectedId === p.node.id ? "selected" : "",
+        matched.value ? "matched" : "",
+      ]
+        .filter(Boolean)
+        .join(" ")
+    );
+    return () =>
+      h("div", { class: "asset-tree-branch" }, [
+        h(
+          "div",
+          {
+            role: "button",
+            tabIndex: 0,
+            class: cardClass.value,
+            onClick: () => p.onOpen && p.onOpen(p.node),
+            onKeydown: (e) => {
+              if (e.key === "Enter" && p.onOpen) p.onOpen(p.node);
+            },
+          },
+          [
+            h("span", p.node.node_type === "group" ? "◇" : "◆"),
+            h("b", p.node.name),
+            h("small", p.node.template_name),
+            p.node.children.length
+              ? h(
+                  UiButton,
+                  {
+                    title: expanded.value ? "收起下级节点" : "展开下级节点",
+                    "aria-label": expanded.value ? `收起${p.node.name}` : `展开${p.node.name}`,
+                    onClick: (e) => {
+                      e.stopPropagation();
+                      if (p.onSelect) p.onSelect(p.node.id);
+                      expanded.value = !expanded.value;
+                    },
+                  },
+                  () => (expanded.value ? "−" : "+")
+                )
+              : h(
+                  UiButton,
+                  {
+                    title: "添加子节点",
+                    "aria-label": `为${p.node.name}添加子节点`,
+                    onClick: (e) => {
+                      e.stopPropagation();
+                      if (p.onSelect) p.onSelect(p.node.id);
+                      if (p.onAddChild) p.onAddChild(p.node);
+                    },
+                  },
+                  () => h(PlusOutlined)
+                ),
+          ]
+        ),
+        expanded.value && p.node.children.length > 0
+          ? h(
+              "div",
+              { class: "asset-tree-children" },
+              p.node.children.map((child) =>
+                h(AssetTreeBranch, {
+                  key: child.id,
+                  node: child,
+                  selectedId: p.selectedId,
+                  search: p.search,
+                  onSelect: p.onSelect,
+                  onOpen: p.onOpen,
+                  onAddChild: p.onAddChild,
+                })
+              )
+            )
+          : null,
+      ]);
+  },
+});
+</script>
+
+<template>
+  <div class="manual-asset-editor">
+    <div v-if="loading" class="asset-editor-loading"><span>正在打开当前站点资产组…</span></div>
+    <div v-else-if="loadError || !group" class="asset-editor-loading error">
+      <span>{{ loadError || "资产组不可用" }}</span>
+      <PrimaryButton secondary @click="loadGroup">重新加载</PrimaryButton>
+    </div>
+    <template v-else>
+      <div class="asset-editor-title">
+        <div><h2>资产组详情</h2><span>当前站点:{{ site.name || "未命名站点" }}</span></div>
+        <PrimaryButton :disabled="saving" @click="onSaveClick">{{ saving ? "保存中…" : "保存" }}</PrimaryButton>
+      </div>
+      <div class="asset-editor-workbench">
+        <aside class="asset-editor-side">
+          <Field label="站点资产组">
+            <UiInput :value="draft.name" @change="(e) => (draft = { ...draft, name: e.target.value })" />
+          </Field>
+          <UiButton class="asset-batch-entry" @click="batchOpen = true">批量创建</UiButton>
+          <div class="asset-template-panel">
+            <strong>资产模板</strong>
+            <UiSelect aria-label="资产模板分类" :value="templateCategory" @change="(e) => setTemplateCategory(e.target.value)">
+              <option value="site">场站设备类别</option>
+              <option value="unbound">不绑定设备的类别</option>
+            </UiSelect>
+            <UiButton
+              v-for="template in templateList"
+              :key="template"
+              @click="addNode(selected, template)"
+            >
+              <span>{{ templateCategory === "site" ? "◆" : "◇" }}</span>
+              <b>{{ template }}</b>
+              <small>点击添加到当前选中节点</small>
+            </UiButton>
+          </div>
+        </aside>
+
+        <section class="asset-tree-workspace">
+          <div class="asset-editor-toolbar">
+            <div class="asset-tool-group">
+              <AntButton type="text" class="asset-tool-button" title="复制节点" aria-label="复制节点" :disabled="!selected" :icon="h(CopyOutlined)" @click="() => copySelected(false)" />
+              <AntButton type="text" class="asset-tool-button" title="深度复制节点及其下级" aria-label="深度复制节点" :disabled="!selected" :icon="h(SnippetsOutlined)" @click="() => copySelected(true)" />
+              <AntButton type="text" class="asset-tool-button" title="节点属性设置" aria-label="节点属性设置" :disabled="!selected" :icon="h(ControlOutlined)" @click="() => editSelected('properties')" />
+              <AntButton type="text" class="asset-tool-button" title="删除节点" aria-label="删除节点" danger :disabled="!selected" :icon="h(DeleteOutlined)" @click="deleteNode" />
+              <span class="asset-tool-divider" />
+              <AntButton type="text" class="asset-tool-button primary" title="添加同级节点" aria-label="添加同级节点" :disabled="!selected" :icon="h(PlusSquareOutlined)" @click="addSibling" />
+              <AntButton type="text" class="asset-tool-button primary" title="添加子节点" aria-label="添加子节点" :icon="h(PlusOutlined)" @click="() => addNode(selected)" />
+              <Dropdown
+                trigger="['click']"
+                :menu="{ items: assetTemplates.map((name) => ({ key: name, label: `应用${name}` })), onClick: ({ key }) => bindTemplate(key) }"
+              >
+                <AntButton type="text" class="asset-tool-button primary" title="应用资产模板" aria-label="应用资产模板" :disabled="!selected" :icon="h(BlockOutlined)" />
+              </Dropdown>
+            </div>
+            <div class="asset-view-switch" role="group" aria-label="视图切换">
+              <UiButton :class="viewMode === 'tree' ? 'active' : ''" title="资产树视图" @click="setViewMode('tree')"><ApartmentOutlined /></UiButton>
+              <UiButton :class="viewMode === 'list' ? 'active' : ''" title="列表视图" @click="setViewMode('list')"><BarsOutlined /></UiButton>
+            </div>
+            <div class="asset-canvas-tools">
+              <AntButton type="text" class="asset-tool-button" title="缩小" aria-label="缩小" :icon="h(ZoomOutOutlined)" @click="changeZoom((v) => Math.max(0.55, Number((v - 0.1).toFixed(2))))" />
+              <AntButton type="text" class="asset-tool-button" title="放大" aria-label="放大" :icon="h(ZoomInOutlined)" @click="changeZoom((v) => Math.min(1.6, Number((v + 0.1).toFixed(2))))" />
+              <AntButton type="text" class="asset-tool-button" title="适合窗口" aria-label="适合窗口" :icon="h(AimOutlined)" @click="fitCanvas" />
+              <label class="asset-tree-search"><SearchOutlined /><UiInput :value="search" @change="(e) => (search = e.target.value)" placeholder="搜索资产节点" /></label>
+            </div>
+          </div>
+
+          <div
+            v-if="viewMode === 'tree'"
+            :class="dragging ? 'asset-tree-canvas dragging' : 'asset-tree-canvas'"
+            @pointerdown="onCanvasPointerDown"
+            @pointermove="onCanvasPointerMove"
+            @pointerup="onCanvasPointerUp"
+          >
+            <div class="asset-tree-stage" :style="{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }">
+              <div class="asset-node-tree">
+                <AssetTreeBranch
+                  v-for="node in draft.structure"
+                  :key="node.id"
+                  :node="node"
+                  :selected-id="selectedId"
+                  :search="search"
+                  :on-select="(id) => (selectedId = id)"
+                  :on-open="openNode"
+                  :on-add-child="addNode"
+                />
+              </div>
+            </div>
+          </div>
+          <div v-else class="asset-node-list">
+            <div class="asset-node-list-head">
+              <span>资产名称</span><span>节点类型</span><span>资产模板</span><span>下级节点</span>
+            </div>
+            <template v-if="visibleRows.length">
+              <UiButton
+                v-for="{ node, depth } in visibleRows"
+                :key="node.id"
+                :class="selectedId === node.id ? 'selected' : ''"
+                @click="openNode(node)"
+              >
+                <span :style="{ paddingLeft: depth * 22 + 'px' }">{{ node.node_type === "group" ? "◇" : "◆" }} {{ node.name }}</span>
+                <span>{{ node.node_type === "group" ? "资产分组" : "设备资产" }}</span>
+                <span>{{ node.template_name || "—" }}</span>
+                <span>{{ node.children.length }}</span>
+              </UiButton>
+            </template>
+            <div v-else class="asset-tree-empty"><b>没有匹配的资产节点</b><span>请更换搜索关键词。</span></div>
+          </div>
+        </section>
+      </div>
+
+      <!-- node config drawer -->
+      <Drawer
+        class="asset-node-drawer"
+        title="节点配置"
+        :width="'58vw'"
+        :open="nodeOpen"
+        @close="cancelNode"
+      >
+        <template #footer>
+          <div class="node-drawer-footer">
+            <PrimaryButton secondary @click="cancelNode">取消</PrimaryButton>
+            <PrimaryButton
+              :disabled="!nodeDraft?.name.trim() || !nodeDraft?.alias?.trim() || !nodeDraft?.template_name.trim() || !nodeDraft?.asset_type?.trim()"
+              @click="saveNode"
+            >确定</PrimaryButton>
+          </div>
+        </template>
+        <UiTabbedRegion>
+          <template #navigation>
+            <div class="node-drawer-tabs">
+              <UiButton :class="nodeTab === 'basic' ? 'active' : ''" @click="setNodeTab('basic')">基础信息设定</UiButton>
+              <UiButton :class="nodeTab === 'properties' ? 'active' : ''" @click="setNodeTab('properties')">属性设定</UiButton>
+              <UiButton :class="nodeTab === 'binding' ? 'active' : ''" @click="setNodeTab('binding')">数据绑定</UiButton>
+            </div>
+          </template>
+
+          <div v-if="nodeDraft && nodeTab === 'basic'" class="node-config-page">
+            <section class="node-config-section"><h3>基础设定</h3>
+              <div class="node-id-row"><span>节点ID</span><b>{{ nodeDraft.id.replace("node-", "").slice(0, 10) }}</b></div>
+              <div class="node-basic-grid">
+                <Field label="名称" required><UiInput :value="nodeDraft.name" @change="(e) => patchNode({ name: e.target.value })" /></Field>
+                <Field label="编号/别名" required><UiInput :value="nodeDraft.alias || ''" @change="(e) => patchNode({ alias: e.target.value })" /></Field>
+                <Field label="模板" required>
+                  <UiSelect :value="nodeDraft.template_name" @change="(e) => patchNode({ template_name: e.target.value })">
+                    <option v-for="name in Array.from(new Set([...assetTemplates, nodeDraft.template_name]))" :key="name">{{ name }}</option>
+                  </UiSelect>
+                </Field>
+                <Field label="类型" required><UiInput :value="nodeDraft.asset_type || ''" @change="(e) => patchNode({ asset_type: e.target.value })" /></Field>
+                <Field label="节点描述"><UiInput :value="nodeDraft.description" placeholder="请输入" @change="(e) => patchNode({ description: e.target.value })" /></Field>
+              </div>
+            </section>
+            <section class="node-config-section"><h3>图片</h3>
+              <div class="node-image-row">
+                <label class="node-image-preview">
+                  <input type="file" accept="image/jpeg,image/png,image/bmp,image/svg+xml" hidden @change="onNodeImageChange" />
+                  <img v-if="nodeDraft.image_data" :src="nodeDraft.image_data" alt="节点图片" />
+                  <BlockOutlined v-else />
+                </label>
+                <div>
+                  <label class="node-upload-link">
+                    <input type="file" accept="image/jpeg,image/png,image/bmp,image/svg+xml" hidden @change="onNodeImageChange" />
+                    <UploadOutlined /> 图片上传
+                  </label>
+                  <small>支持格式 jpeg/jpg/png/bmp/svg,文件不超过 2 MB</small>
+                  <UiButton v-if="nodeDraft.image_data" class="text-button" @click="patchNode({ image_data: '' })">移除图片</UiButton>
+                </div>
+              </div>
+            </section>
+            <section class="node-config-section"><h3>节点二维码</h3>
+              <QRCode :size="112" :value="`stationbuilder://site/${group.site_id}/asset/${nodeDraft.id}`" :bordered="false" />
+            </section>
+          </div>
+
+          <div v-if="nodeDraft && nodeTab === 'properties'" class="node-config-page">
+            <section class="node-config-section"><h3>基础属性</h3>
+              <div class="node-property-grid">
+                <Field v-for="name in assetPropertyFields" :key="name" :label="name">
+                  <UiInput
+                    :type="name.includes('日期') ? 'date' : 'text'"
+                    :value="assetPropertyValue(nodeDraft, name) || (name === '设备运行状态计算阈值' ? '5' : '')"
+                    :placeholder="name === '设备运行状态计算阈值' ? '5' : '请输入'"
+                    @change="(e) => applyAssetProperty(name, e.target.value)"
+                  />
+                </Field>
+              </div>
+            </section>
+          </div>
+
+          <div v-if="nodeDraft && nodeTab === 'binding'" class="node-config-page binding-page">
+            <section class="node-config-section"><h3>边缘端绑定</h3><h4>组件列表</h4>
+              <div class="edge-component-table">
+                <header><span>边缘组件</span><span>采集类型</span><span>群组</span><span>设备名称</span><span>操作</span></header>
+                <div v-for="component in bindingComponents" :key="component" :class="bindingComponent === component ? 'active' : ''">
+                  <b>{{ component }}</b>
+                  <span>{{ nodeDraft.bindings.find((b) => b.source === component)?.collect_type || "—" }}</span>
+                  <span>{{ nodeDraft.bindings.find((b) => b.source === component)?.group || "—" }}</span>
+                  <span>{{ nodeDraft.bindings.find((b) => b.source === component)?.device_name || "—" }}</span>
+                  <span>
+                    <UiButton title="编辑点位" @click="selectBindingComponent(component)"><EditOutlined /></UiButton>
+                    <UiButton title="绑定边缘设备" @click="bindEdgeComponent(component)"><LinkOutlined /></UiButton>
+                  </span>
+                </div>
+              </div>
+            </section>
+            <section class="node-config-section"><h3>点位绑定</h3>
+              <UiTabbedRegion>
+                <template #navigation>
+                  <div class="binding-component-tabs">
+                    <UiButton v-for="component in bindingComponents" :key="component" :class="bindingComponent === component ? 'active' : ''" @click="selectBindingComponent(component)">{{ component }}</UiButton>
+                  </div>
+                </template>
+                <div class="binding-parameter-head"><h4>参数列表</h4><PrimaryButton @click="bindingImportOpen = true">+ 批量导入</PrimaryButton></div>
+                <div class="binding-parameter-table">
+                  <header><span>名称</span><span>采集点</span><span>单位</span><span>精度</span><span>映射</span><span>描述</span><span>操作</span></header>
+                  <div v-for="(item, index) in nodeDraft.bindings.filter((b) => b.source === bindingComponent)" :key="bindingComponent + '-' + index">
+                    <UiInput :value="item.name || ''" placeholder="参数名称" @change="(e) => updateBinding(index, { name: e.target.value })" />
+                    <UiInput :value="item.point" placeholder="采集点" @change="(e) => updateBinding(index, { point: e.target.value })" />
+                    <UiInput :value="item.unit || ''" @change="(e) => updateBinding(index, { unit: e.target.value })" />
+                    <UiInput :value="item.precision || ''" @change="(e) => updateBinding(index, { precision: e.target.value })" />
+                    <UiInput :value="item.mapping || ''" @change="(e) => updateBinding(index, { mapping: e.target.value })" />
+                    <UiInput :value="item.description || ''" @change="(e) => updateBinding(index, { description: e.target.value })" />
+                    <UiButton class="trash" title="删除参数" @click="removeBinding(index)"><DeleteOutlined /></UiButton>
+                  </div>
+                  <div v-if="!nodeDraft.bindings.some((b) => b.source === bindingComponent)" class="binding-empty">暂无数据,请先在组件列表中绑定设备或批量导入点位。</div>
+                </div>
+              </UiTabbedRegion>
+            </section>
+          </div>
+        </UiTabbedRegion>
+      </Drawer>
+
+      <!-- binding import dialog -->
+      <div v-if="bindingImportOpen" class="dlg-backdrop binding-import-backdrop" @mousedown="bindingImportOpen = false">
+        <div class="dlg-box binding-import-dialog" @mousedown.stop>
+          <h2>批量导入点位参数</h2>
+          <p class="hint">导入到组件:{{ bindingComponent }}</p>
+          <label class="manual-excel-upload">
+            <input type="file" accept=".xlsx,.xls,.csv" hidden @change="onBindingFileChange" />
+            <b>⇧</b><span>点击选择点位文件</span>
+            <small>表头支持:名称、采集点、单位、精度、映射、描述</small>
+          </label>
+          <UiActions className="dlg-actions">
+            <PrimaryButton secondary @click="bindingImportOpen = false">取消</PrimaryButton>
+          </UiActions>
+        </div>
+      </div>
+
+      <!-- batch create dialog -->
+      <UiOverlay v-if="batchOpen" class="dlg-backdrop" :onMouseDown="() => (batchOpen = false)">
+        <div class="dlg-box batch-create-dialog" @mousedown.stop>
+          <h2>批量创建资产节点</h2>
+          <UiTabbedRegion>
+            <template #navigation>
+              <div class="node-drawer-tabs">
+                <UiButton :class="batchTab === 'online' ? 'active' : ''" @click="setBatchTab('online')">在线配置</UiButton>
+                <UiButton :class="batchTab === 'excel' ? 'active' : ''" @click="setBatchTab('excel')">Excel 导入</UiButton>
+              </div>
+            </template>
+            <div v-if="batchTab === 'online'" class="batch-table">
+              <header><span>资产模板</span><span>数量</span><span>命名规则</span><span></span></header>
+              <div v-for="(row, index) in batchRows" :key="index">
+                <UiInput :value="row.template_name" @change="(e) => updateBatchRow(index, { template_name: e.target.value })" />
+                <UiInput type="number" min="1" :value="row.count" @change="(e) => updateBatchRow(index, { count: Number(e.target.value) })" />
+                <UiInput :value="row.rule" @change="(e) => updateBatchRow(index, { rule: e.target.value })" />
+                <UiButton class="trash" @click="removeBatchRow(index)">×</UiButton>
+              </div>
+              <PrimaryButton secondary @click="addBatchRow">+ 添加一行</PrimaryButton>
+            </div>
+            <label v-else class="manual-excel-upload">
+              <input type="file" accept=".xlsx,.xls,.csv" hidden @change="onBatchFileChange" />
+              <b>⇧</b><span>点击选择资产配置文件</span>
+              <small>表头支持:设备类型或资产模板、数量、命名规则</small>
+            </label>
+          </UiTabbedRegion>
+          <UiActions className="dlg-actions">
+            <PrimaryButton secondary @click="batchOpen = false">取消</PrimaryButton>
+            <PrimaryButton :disabled="batchTab !== 'online' || !batchRows.some((r) => r.template_name && r.count > 0)" @click="createBatch">创建节点</PrimaryButton>
+          </UiActions>
+        </div>
+      </UiOverlay>
+    </template>
+  </div>
+</template>
diff --git a/src-vue/pages/PageConfig.vue b/src-vue/pages/PageConfig.vue
new file mode 100644 (file)
index 0000000..524b331
--- /dev/null
@@ -0,0 +1,323 @@
+<script setup>
+import { ref, computed, watch, onUnmounted, h } from "vue";
+import { Drawer, Button } from "ant-design-vue";
+import { EyeOutlined } from "@ant-design/icons-vue";
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import SearchInput from "../components/SearchInput.vue";
+import { effectiveStandardPageKeys } from "../site-menu-model.js";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  leaves: { type: Array, default: () => [] },
+  onMuChange: { type: Function, default: null },
+  onPageConfigChange: { type: Function, default: null },
+  onSiteChange: { type: Function, default: null },
+  renderDashboard: { type: Function, default: () => null },
+  renderDomainDashboard: { type: Function, default: () => null },
+  notify: { type: Function, default: () => {} },
+  registerSave: { type: Function, default: null },
+});
+
+// Mirror React api(): fetch /api/db + JSON; no-body calls default to GET.
+async function api(path, body, method = "POST") {
+  const res = await fetch(
+    `/api/db${path}`,
+    body === undefined
+      ? undefined
+      : { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
+  );
+  if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
+  return res.json();
+}
+
+const query = ref("");
+const saving = ref(false);
+const preview = ref(null);
+const catalog = ref({ pages: [], local_cubes: [] });
+const catalogLoading = ref(true);
+const catalogError = ref(false);
+let active = true;
+onUnmounted(() => {
+  active = false;
+});
+
+watch(
+  () => [props.site?.id, props.site?.template_id, props.site?.domain],
+  async () => {
+    if (!props.site?.id) return;
+    catalogLoading.value = true;
+    catalogError.value = false;
+    try {
+      const data = await api(`/sites/${props.site.id}/domain-dashboards`);
+      if (active) catalog.value = data;
+    } catch (error) {
+      if (active) {
+        catalogError.value = true;
+        props.notify("加载领域仪表板失败:" + error.message);
+      }
+    } finally {
+      if (active) catalogLoading.value = false;
+    }
+  },
+  { immediate: true }
+);
+
+const domainCards = computed(() =>
+  (catalog.value.pages || []).map((page) => ({
+    key: `domain-${page.id}`,
+    name: page.name,
+    page,
+    cube: (props.site?.cubes || []).find((cube) => cube.id === page.cube_id),
+  }))
+);
+const dashboardCards = computed(() => [
+  ...domainCards.value,
+  ...(props.site?.cubes || [])
+    .filter((cube) => !domainCards.value.some((card) => card.cube && card.cube.id === cube.id))
+    .map((cube) => ({ key: `cube-${cube.id}`, name: cube.name, cube, page: null })),
+]);
+
+function moduleGroup(leaf) {
+  const name = leaf.parentMenu || leaf.label;
+  if (/设置|设定|分类|设备类型/.test(name)) return "系统与业务设置";
+  if (/维修/.test(name)) return "维修管理";
+  if (/保养/.test(name)) return "保养管理";
+  if (/点检|异常工单/.test(name)) return "点检管理";
+  if (/备件|仓库|库位|出入库/.test(name)) return "备件管理";
+  if (/台账/.test(name)) return "资产台账";
+  if (/工单中心|表单|流程|定时/.test(name)) return "工单与流程";
+  return name;
+}
+
+const grouped = computed(() => {
+  const groups = new Map();
+  for (const leaf of props.leaves.filter(
+    (leaf) => !query.value || `${leaf.label} ${leaf.parentMenu}`.includes(query.value)
+  )) {
+    const group = moduleGroup(leaf);
+    if (!groups.has(group)) groups.set(group, []);
+    groups.get(group).push(leaf);
+  }
+  return [...groups.entries()];
+});
+
+watch(
+  [
+    saving,
+    () => props.site?.page_config?.standard_pages_initialized,
+    () => (props.site?.mu || []).join("|"),
+    () => props.leaves.length,
+  ],
+  (_, __, onInvalidate) => {
+    let fn = null;
+    if (saving.value) fn = async () => { throw new Error("页面选择正在保存,请稍后重试"); };
+    else if (
+      !props.site?.mu?.length &&
+      !props.site?.page_config?.standard_pages_initialized &&
+      props.leaves.length
+    )
+      fn = async () => {
+        await props.onMuChange(effectiveStandardPageKeys(props.site, props.leaves));
+      };
+    props.registerSave && props.registerSave(fn);
+    onInvalidate(() => props.registerSave && props.registerSave(null));
+  },
+  { immediate: true }
+);
+
+const selectedMu = computed(() => effectiveStandardPageKeys(props.site, props.leaves));
+const dashboardIds = computed(
+  () => props.site?.page_config?.dashboard_ids ?? props.site?.cubes?.map((cube) => cube.id) ?? []
+);
+const twinIds = computed(() => props.site?.page_config?.twin_ids || []);
+const twins = [
+  { id: "site-overview", name: "场站全景", description: "SC 数字孪生场景画面" },
+  { id: "process-scene", name: "工艺与设备场景", description: "SC 数字孪生场景画面" },
+];
+
+function dashboardImage(card, large = false) {
+  return card.cube
+    ? props.renderDashboard(card.cube, large)
+    : props.renderDomainDashboard(card.page, large);
+}
+function cardSelected(card) {
+  return !!card.cube && dashboardIds.value.includes(card.cube.id);
+}
+
+const RenderVNode = (p) => p.node;
+
+function enabledItems(items) {
+  return items.filter((item) => !item.disabled);
+}
+function groupAllSelected(items) {
+  const enabled = enabledItems(items);
+  return enabled.length > 0 && enabled.every((item) => selectedMu.value.includes(item.key));
+}
+async function changeMu(keys) {
+  if (saving.value) return;
+  saving.value = true;
+  try {
+    await props.onMuChange(keys);
+  } catch (error) {
+    props.notify("保存页面选择失败:" + error.message);
+  } finally {
+    saving.value = false;
+  }
+}
+function changeGroupMu(items) {
+  const enabled = enabledItems(items);
+  if (groupAllSelected(items)) {
+    changeMu(selectedMu.value.filter((key) => !enabled.some((item) => item.key === key)));
+  } else {
+    changeMu(Array.from(new Set([...selectedMu.value, ...enabled.map((item) => item.key)])));
+  }
+}
+function changeItemMu(item) {
+  changeMu(
+    selectedMu.value.includes(item.key)
+      ? selectedMu.value.filter((key) => key !== item.key)
+      : [...selectedMu.value, item.key]
+  );
+}
+async function changeConfig(config) {
+  if (saving.value) return;
+  saving.value = true;
+  try {
+    await props.onPageConfigChange(config);
+  } catch (error) {
+    props.notify("保存页面选择失败:" + error.message);
+  } finally {
+    saving.value = false;
+  }
+}
+async function changeDomainDashboard(page, selected) {
+  if (saving.value) return;
+  saving.value = true;
+  try {
+    const result = await api(`/sites/${props.site.id}/domain-dashboards`, { page_id: page.id, selected }, "PUT");
+    catalog.value = result.catalog;
+    props.onSiteChange && props.onSiteChange(result.site);
+  } catch (error) {
+    props.notify("加载领域仪表板失败:" + error.message);
+  } finally {
+    saving.value = false;
+  }
+}
+function changeDashboard(card) {
+  if (card.page) changeDomainDashboard(card.page, !cardSelected(card));
+  else
+    changeConfig({
+      ...props.site.page_config,
+      dashboard_ids: cardSelected(card)
+        ? dashboardIds.value.filter((id) => id !== card.cube.id)
+        : [...dashboardIds.value, card.cube.id],
+      twin_ids: twinIds.value,
+    });
+}
+function changeTwin(twin) {
+  changeConfig({
+    ...props.site.page_config,
+    dashboard_ids: dashboardIds.value,
+    twin_ids: twinIds.value.includes(twin.id)
+      ? twinIds.value.filter((id) => id !== twin.id)
+      : [...twinIds.value, twin.id],
+  });
+}
+</script>
+
+<template>
+  <div v-if="!site" class="empty-state">正在加载站点…</div>
+  <div v-else class="station-page-selection">
+    <section class="station-page-module-panel">
+      <header>
+        <div>
+          <h2>选择站点需要的功能页面</h2>
+          <p>选择可用的标准页面、领域仪表板及数字孪生场景;菜单名称、层级和顺序在下一步设置。</p>
+        </div>
+        <SearchInput :value="query" @change="(v) => (query = v)" placeholder="搜索功能或页面名称" />
+      </header>
+      <div class="station-selection-summary">
+        <span>标准页面 <b>{{ selectedMu.length }}</b></span>
+        <span>领域仪表板 <b>{{ dashboardIds.length }}</b></span>
+        <span>数字孪生场景 <b>{{ twinIds.length }}</b></span>
+        <span>{{ saving ? "保存中…" : "选择后自动保存" }}</span>
+      </div>
+      <div class="station-module-grid">
+        <section v-for="[name, items] in grouped" :key="name">
+          <header>
+            <label>
+              <UiInput type="checkbox" :disabled="saving" :checked="groupAllSelected(items)" @change="() => changeGroupMu(items)" />
+              <b>{{ name || "标准功能" }}</b>
+            </label>
+            <span>{{ items.filter((item) => selectedMu.includes(item.key)).length }} / {{ items.length }}</span>
+          </header>
+          <div>
+            <label v-for="item in items" :key="item.key">
+              <UiInput type="checkbox" :disabled="saving || item.disabled" :checked="selectedMu.includes(item.key)" @change="() => changeItemMu(item)" />
+              {{ item.label }}
+            </label>
+          </div>
+        </section>
+      </div>
+    </section>
+    <section class="station-page-module-panel">
+      <header>
+        <div>
+          <h2>领域扩展仪表板</h2>
+          <p>{{ catalog.template_name ? `${catalog.template_name} · 预览领域页面,勾选后加入当前站点。` : "预览对应领域的仪表板,选择站点需要的页面。" }}</p>
+        </div>
+        <span>{{ dashboardIds.length }} / {{ dashboardCards.length }} 个已选择</span>
+      </header>
+      <div class="station-dashboard-selection">
+        <article v-for="card in dashboardCards" :key="card.key" :class="cardSelected(card) ? 'selected' : ''">
+          <UiButton type="button" className="station-dashboard-image" :aria-label="`预览${card.name}画面`" @click="() => (preview = card)">
+            <RenderVNode :node="dashboardImage(card)" />
+          </UiButton>
+          <footer>
+            <label>
+              <UiInput type="checkbox" :aria-label="`选择仪表板${card.name}`" :disabled="saving || catalogLoading" :checked="cardSelected(card)" @change="() => changeDashboard(card)" />
+              {{ card.name }}
+            </label>
+            <Button type="text" :aria-label="`预览${card.name}`" @click="() => (preview = card)">
+              <template #icon><EyeOutlined /></template>
+            </Button>
+          </footer>
+        </article>
+      </div>
+      <div v-if="catalogLoading || catalogError || !dashboardCards.length" class="station-template-empty">
+        {{ catalogLoading ? "正在加载领域仪表板…" : catalogError ? "领域仪表板暂时无法加载,请刷新后重试。" : "当前领域暂无可预览的仪表板,可先完善领域模板。" }}
+      </div>
+    </section>
+    <section class="station-page-module-panel">
+      <header>
+        <div>
+          <h2>数字孪生场景</h2>
+          <p>SC 场景选择,当前画面为设计模拟数据。</p>
+        </div>
+        <span>{{ twinIds.length }} 个已选择</span>
+      </header>
+      <div class="station-twin-selection">
+        <article v-for="twin in twins" :key="twin.id">
+          <div class="station-scene-thumb"><span>SC 场景示意</span><i /><i /><i /><i /><div /></div>
+          <footer>
+            <label>
+              <UiInput type="checkbox" :disabled="saving" :checked="twinIds.includes(twin.id)" @change="() => changeTwin(twin)" />
+              {{ twin.name }}
+            </label>
+            <small>模拟画面</small>
+          </footer>
+        </article>
+      </div>
+    </section>
+    <Drawer
+      className="station-info-drawer station-dashboard-preview-drawer"
+      :width="'80vw'"
+      :title="preview?.name || '仪表板预览'"
+      :open="!!preview"
+      @close="() => (preview = null)"
+    >
+      <RenderVNode v-if="preview" :node="dashboardImage(preview, true)" />
+    </Drawer>
+  </div>
+</template>
diff --git a/src-vue/pages/Placeholder.vue b/src-vue/pages/Placeholder.vue
new file mode 100644 (file)
index 0000000..54a905e
--- /dev/null
@@ -0,0 +1,17 @@
+<script setup>
+import Panel from "../components/Panel.vue";
+
+const props = defineProps({
+  title: { type: String, default: "页面" },
+  subtitle: { type: String, default: "" },
+});
+</script>
+
+<template>
+  <Panel :title="title">
+    <div class="empty-state">
+      <p>「{{ title }}」页面正在移植中(Vue3 重写进行中)。</p>
+      <p v-if="subtitle" class="muted">{{ subtitle }}</p>
+    </div>
+  </Panel>
+</template>
diff --git a/src-vue/pages/Preview.vue b/src-vue/pages/Preview.vue
new file mode 100644 (file)
index 0000000..772e538
--- /dev/null
@@ -0,0 +1,145 @@
+<script setup>
+import { ref, computed, watch } from "vue";
+import { Button } from "ant-design-vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiButton from "../ui/UiButton.vue";
+import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
+import StatusTag from "../components/StatusTag.vue";
+import { fetchDb } from "../lib/api.js";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  tab: { type: String, default: "info" },
+  setTab: { type: Function, default: () => {} },
+  setModal: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+});
+
+const cubeIdx = ref(0);
+const cube = computed(() => props.site?.cubes?.[cubeIdx.value]);
+
+const kpiData = [
+  ["设备在线率", "92%", "23 / 25"],
+  ["今日总能耗", "12,480", "kWh"],
+  ["未处理告警", "7", "高危 2"],
+  ["平均负载率", "68%", "较昨日 +3%"],
+];
+
+const jsonText = ref("");
+const loadingJson = ref(false);
+
+watch(
+  [() => props.tab, cubeIdx],
+  async () => {
+    if (props.tab === "json" && cube.value && cube.value.id && props.site) {
+      loadingJson.value = true;
+      try {
+        const d = await fetchDb(`/sites/${props.site.id}/cubes/${cube.value.id}/file`);
+        jsonText.value = JSON.stringify(d, null, 2);
+      } catch (e) {
+        jsonText.value = "加载失败:" + (e?.message || e);
+      } finally {
+        loadingJson.value = false;
+      }
+    }
+  },
+  { immediate: true }
+);
+</script>
+
+<template>
+  <div class="preview-shell">
+    <div class="preview-toolbar">
+      <div>
+        <UiSelect :value="cubeIdx" @change="(e) => (cubeIdx = Number(e.target.value))">
+          <option v-for="(c, i) in (site?.cubes || [])" :key="i" :value="i">{{ c.name }}</option>
+        </UiSelect>
+        <StatusTag>{{ cube?.status === "generated" ? "校验通过" : "未生成" }}</StatusTag>
+      </div>
+      <div>
+        <UiButton>1440 × 900⌄</UiButton>
+        <UiButton>☼ 浅色</UiButton>
+        <UiButton @click="() => notify('模拟数据已刷新')">↻ 刷新数据</UiButton>
+        <UiButton>⛶ 全屏</UiButton>
+      </div>
+    </div>
+
+    <UiTabbedRegion>
+      <template #navigation>
+        <UiButton :aria-selected="tab === 'info'" @click="setTab('info')">信息</UiButton>
+        <UiButton :aria-selected="tab === 'json'" @click="setTab('json')">JSON</UiButton>
+        <UiButton :aria-selected="tab === 'feedback'" @click="setTab('feedback')">反馈</UiButton>
+      </template>
+
+      <div v-if="tab === 'info'" class="preview-main">
+        <div class="cube-canvas">
+          <div class="cube-top">
+            <b>⬡ iStation</b>
+            <span>{{ site?.name || "未命名站点" }}</span>
+            <i>⌕ ♧ ? ▦ W</i>
+          </div>
+          <aside class="cube-side">
+            <strong>站点监控</strong>
+            <UiButton class="active">▦ {{ cube?.name || "运行总览" }}</UiButton>
+            <UiButton>▧ 设备管理</UiButton>
+            <UiButton>▥ 能源管理</UiButton>
+            <UiButton>△ 告警中心</UiButton>
+            <UiButton>⚙ 系统设置</UiButton>
+          </aside>
+          <div class="cube-content">
+            <header>
+              <div>
+                <small>站点监控 / {{ cube?.name }}</small>
+                <h2>{{ site?.name || "未命名站点" }}</h2>
+              </div>
+              <span>最后更新:刚刚 ↻</span>
+            </header>
+            <div class="cube-kpis">
+              <div v-for="(x, i) in kpiData" :key="x[0]">
+                <span>{{ x[0] }}</span>
+                <b>{{ x[1] }}</b>
+                <small>{{ x[2] }}</small>
+                <i :class="'spark s' + i"></i>
+              </div>
+            </div>
+            <div class="cube-grid">
+              <section class="line-panel">
+                <h3>近 7 日能源消耗 <span>本周⌄</span></h3>
+                <div class="line-chart">
+                  <div></div>
+                  <svg viewBox="0 0 500 150" preserveAspectRatio="none">
+                    <polyline points="0,112 65,92 130,103 200,56 270,75 335,45 400,62 500,24" fill="none" stroke="#009fe3" stroke-width="3" />
+                    <polyline points="0,132 65,118 130,121 200,98 270,102 335,82 400,91 500,68" fill="none" stroke="#8ecfee" stroke-width="2" />
+                  </svg>
+                  <footer>
+                    <span v-for="x in ['08/08', '08/09', '08/10', '08/11', '08/12', '08/13', '今天']" :key="x">{{ x }}</span>
+                  </footer>
+                </div>
+              </section>
+              <section class="donut-panel">
+                <h3>设备状态分布</h3>
+                <div class="donut">
+                  <svg viewBox="0 0 100 100">
+                    <circle cx="50" cy="50" r="40" fill="none" stroke="#E8E8E8" stroke-width="14" />
+                    <circle cx="50" cy="50" r="40" fill="none" stroke="#009fe3" stroke-width="14" stroke-dasharray="180 251" />
+                  </svg>
+                  <b>72%</b>
+                  <span>在线</span>
+                </div>
+              </section>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div v-else-if="tab === 'json'" class="preview-json">
+        <pre v-if="!loadingJson">{{ jsonText }}</pre>
+        <div v-else class="empty-state">加载中…</div>
+      </div>
+
+      <div v-else class="preview-feedback">
+        <Button @click="() => setModal('feedback')">提出修改意见</Button>
+      </div>
+    </UiTabbedRegion>
+  </div>
+</template>
diff --git a/src-vue/pages/Projects.vue b/src-vue/pages/Projects.vue
new file mode 100644 (file)
index 0000000..00d7846
--- /dev/null
@@ -0,0 +1,442 @@
+<script setup>
+import { ref, computed, watch, onMounted, h } from "vue";
+import { Button, Drawer, Dropdown, Tag } from "ant-design-vue";
+import {
+  ApartmentOutlined, CaretDownOutlined, CaretRightOutlined, CheckOutlined,
+  DeleteOutlined, EditOutlined, EllipsisOutlined, PlusOutlined,
+} from "@ant-design/icons-vue";
+
+import UiButton from "../ui/UiButton.vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
+import UiActions from "../ui/UiActions.vue";
+import SearchInput from "../components/SearchInput.vue";
+import Pagination from "../components/Pagination.vue";
+import { fetchDb } from "../lib/api.js";
+
+const props = defineProps({
+  sites: { type: Array, default: () => [] },
+  domains: { type: Array, default: () => [] }, // { id, name }[]
+  loading: { type: Boolean, default: false },
+  onSave: { type: Function, default: null },
+  onConfigure: { type: Function, default: null },
+  onDelete: { type: Function, default: null },
+  notify: { type: Function, default: () => {} },
+});
+
+const notify = (m) => (props.notify ? props.notify(m) : null);
+
+// ---- templates (domain-templates) ----
+const templates = ref([]);
+const templatesLoading = ref(true);
+const available = computed(() => templates.value.filter((t) => t.status === "available"));
+
+const query = ref("");
+const domain = ref("");
+const status = ref("");
+const collapsed = ref(new Set());
+const page = ref(1);
+const pageSize = ref(10);
+const draft = ref(null);
+const saving = ref(false);
+
+function isConfigured(site) {
+  return !!(site.template_applied_at || site.device_count || site.mu_count || site.cube_count);
+}
+function hasAncestor(sites, id, ancestor) {
+  const seen = new Set();
+  let current = sites.find((s) => s.id === id);
+  while (current && !seen.has(current.id)) {
+    if (current.id === ancestor) return true;
+    seen.add(current.id);
+    current = sites.find((s) => s.id === current.parent_id);
+  }
+  return false;
+}
+function sitePaths(sites, id) {
+  const names = [];
+  const visited = new Set();
+  let current = sites.find((s) => s.id === id);
+  while (current && !visited.has(current.id)) {
+    visited.add(current.id);
+    names.unshift(current.name || "未命名站点");
+    current = sites.find((s) => s.id === current.parent_id);
+  }
+  return names;
+}
+
+const children = computed(() => {
+  const map = new Map();
+  const ids = new Set(props.sites.map((s) => s.id));
+  for (const site of props.sites) {
+    const parent = ids.has(site.parent_id) ? site.parent_id : 0;
+    if (!map.has(parent)) map.set(parent, []);
+    map.get(parent).push(site);
+  }
+  return map;
+});
+
+const visible = computed(() => {
+  const included = new Set();
+  for (const site of props.sites) {
+    if (
+      (!query.value || (site.name || "").toLowerCase().includes(query.value.trim().toLowerCase())) &&
+      (!domain.value || site.domain === domain.value) &&
+      (!status.value || (status.value === "configured" ? isConfigured(site) : !isConfigured(site)))
+    ) {
+      let current = site;
+      const visited = new Set();
+      while (current && !visited.has(current.id)) {
+        visited.add(current.id);
+        included.add(current.id);
+        current = props.sites.find((i) => i.id === current.parent_id);
+      }
+    }
+  }
+  const rows = [];
+  const visited = new Set();
+  const walk = (parent, depth) => {
+    for (const site of children.value.get(parent) || []) {
+      if (!included.has(site.id) || visited.has(site.id)) continue;
+      visited.add(site.id);
+      rows.push({ site, depth });
+      if (!collapsed.value.has(site.id)) walk(site.id, depth + 1);
+    }
+  };
+  walk(0, 0);
+  return rows;
+});
+
+const configuredCount = computed(() => props.sites.filter(isConfigured).length);
+const statusTabs = computed(() => [
+  { key: "", label: "全部站点", count: props.sites.length },
+  { key: "pending", label: "待配置", count: props.sites.length - configuredCount.value },
+  { key: "configured", label: "已配置", count: configuredCount.value },
+]);
+const topCount = computed(() => (children.value.get(0) || []).length);
+const subCount = computed(() => props.sites.length - topCount.value);
+const paged = computed(() =>
+  visible.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)
+);
+const totalPages = computed(() => Math.max(1, Math.ceil(visible.value.length / pageSize.value)));
+
+const draftTemplates = computed(() =>
+  available.value.filter((t) => !draft.value?.domain || t.domain_name === draft.value.domain)
+);
+const selectedTemplate = computed(() =>
+  available.value.find((t) => t.id === Number(draft.value?.template_id))
+);
+
+watch([query, domain, status, collapsed], () => {
+  page.value = 1;
+});
+
+const openCreate = (parentId = null) => {
+  const parent = props.sites.find((s) => s.id === parentId);
+  draft.value = {
+    parent_id: parentId,
+    name: "",
+    domain: parent?.domain || "",
+    description: "",
+    template_id: parent?.template_id || "",
+    mode: "template",
+  };
+};
+const openEdit = async (id) => {
+  try {
+    const site = await fetchDb(`/sites/${id}`);
+    draft.value = { ...site, mode: site.template_id ? "template" : "blank" };
+  } catch (e) {
+    notify("加载站点信息失败:" + (e?.message || e));
+  }
+};
+const toggle = (id) => {
+  const next = new Set(collapsed.value);
+  if (next.has(id)) next.delete(id);
+  else next.add(id);
+  collapsed.value = next;
+};
+const toggleCollapseAll = () => {
+  collapsed.value = collapsed.value.size
+    ? new Set()
+    : new Set(props.sites.filter((s) => (children.value.get(s.id) || []).length).map((s) => s.id));
+};
+const submit = async (keepOpen = false) => {
+  if (saving.value || !draft.value) return;
+  if (!draft.value.name.trim()) {
+    notify("请填写站点名称");
+    return;
+  }
+  if (!draft.value.domain) {
+    notify("请选择所属领域");
+    return;
+  }
+  if (draft.value.mode === "template" && !draft.value.template_id) {
+    notify("请选择站点模板,或切换为空白站点");
+    return;
+  }
+  saving.value = true;
+  try {
+    await props.onSave(
+      {
+        name: draft.value.name.trim(),
+        domain: draft.value.domain,
+        description: draft.value.description,
+        parent_id: draft.value.parent_id || null,
+        template_id: draft.value.mode === "template" ? Number(draft.value.template_id) : null,
+        hierarchy_only: true,
+      },
+      draft.value.id
+    );
+    notify(draft.value.id ? "站点基本信息已保存" : "站点已建立,可继续创建下级站点或稍后配置");
+    if (keepOpen) draft.value = { ...draft.value, id: undefined, name: "", description: "" };
+    else draft.value = null;
+  } catch (e) {
+    notify("保存失败:" + (e?.message || e));
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(() => {
+  fetchDb("/domain-templates")
+    .then((d) => {
+      templates.value = d || [];
+    })
+    .catch((e) => notify("加载模板失败:" + (e?.message || e)))
+    .finally(() => {
+      templatesLoading.value = false;
+    });
+});
+</script>
+
+<template>
+  <div class="station-hierarchy-workspace">
+    <UiTabbedRegion>
+      <template #navigation>
+        <div class="company-status-tabs" role="tablist" aria-label="站点配置状态">
+          <UiButton
+            v-for="item in statusTabs"
+            :key="item.key"
+            role="tab"
+            :aria-selected="status === item.key"
+            :class="status === item.key ? 'active' : ''"
+            @click="() => (status = item.key)"
+          >
+            <span>{{ item.label }}</span><b>{{ item.count }}</b>
+          </UiButton>
+        </div>
+      </template>
+      <section class="station-list-panel">
+        <div class="station-hierarchy-note">
+          <ApartmentOutlined />
+          <span>先建立站点层级,再进入各站点配置。新建时可选择模板,暂不配置站点内部内容。</span>
+          <span>顶级站点 <b>{{ topCount }}</b> · 子站点 <b>{{ subCount }}</b></span>
+        </div>
+        <div class="station-list-toolbar">
+          <SearchInput :value="query" :placeholder="'搜索站点名称'" @change="(v) => (query = v)" />
+          <UiSelect aria-label="站点领域筛选" :value="domain" @change="(e) => (domain = e.target.value)">
+            <option value="">全部领域</option>
+            <option v-for="item in domains" :key="item.id">{{ item.name }}</option>
+          </UiSelect>
+          <div>
+            <Button @click="() => { query = ''; domain = ''; status = ''; }">重置</Button>
+            <Button @click="toggleCollapseAll">{{ collapsed.size ? '展开全部' : '收起全部' }}</Button>
+            <Button type="primary" :icon="h(PlusOutlined)" @click="() => openCreate()">新增站点</Button>
+          </div>
+        </div>
+        <div class="station-table-scroll">
+          <UiTable class="data-table company-table station-hierarchy-table">
+            <thead>
+              <tr>
+                <th>站点名称</th>
+                <th>所属领域</th>
+                <th>站点模板</th>
+                <th>配置状态</th>
+                <th>子站点</th>
+                <th>最近修改</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="row in paged" :key="row.site.id">
+                <td>
+                  <div class="station-name-cell" :style="{ paddingLeft: row.depth * 24 + 'px' }">
+                    <template v-if="(children.get(row.site.id) || []).length > 0">
+                      <UiButton
+                        :aria-label="(collapsed.has(row.site.id) ? '展开' : '收起') + row.site.name"
+                        @click="toggle(row.site.id)"
+                      >
+                        <CaretRightOutlined v-if="collapsed.has(row.site.id)" />
+                        <CaretDownOutlined v-else />
+                      </UiButton>
+                    </template>
+                    <span v-else class="station-tree-spacer" />
+                    <UiButton class="link" @click="() => openEdit(row.site.id)">{{ row.site.name || '未命名站点' }}</UiButton>
+                    <small v-if="row.depth === 0">顶级</small>
+                  </div>
+                </td>
+                <td>{{ row.site.domain || '—' }}</td>
+                <td>
+                  <span class="station-template-name">{{ row.site.template_name || '未选择模板' }}</span>
+                  <small v-if="row.site.template_id && !row.site.template_applied_at" class="station-pending-template">待应用</small>
+                </td>
+                <td>
+                  <Tag :color="isConfigured(row.site) ? 'success' : 'default'">{{ isConfigured(row.site) ? '已配置' : '待配置' }}</Tag>
+                </td>
+                <td>{{ (children.get(row.site.id) || []).length }}</td>
+                <td>{{ row.site.updated_at || '—' }}</td>
+                <td>
+                  <div class="station-table-actions">
+                    <Button type="link" @click="() => props.onConfigure && props.onConfigure(row.site.id)">配置站点</Button>
+                    <Dropdown
+                      :trigger="['click']"
+                      :menu="{
+                        items: [
+                          { key: 'basic', label: '编辑基本信息', icon: h(EditOutlined) },
+                          { key: 'child', label: '新增子站点', icon: h(PlusOutlined) },
+                          { key: 'delete', label: '删除站点', danger: true, icon: h(DeleteOutlined) },
+                        ],
+                        onClick: ({ key }) => {
+                          if (key === 'basic') openEdit(row.site.id);
+                          else if (key === 'child') openCreate(row.site.id);
+                          else if (props.onDelete) props.onDelete([row.site.id]);
+                        },
+                      }"
+                    >
+                      <Button type="text" :icon="h(EllipsisOutlined)" :aria-label="row.site.name + '更多操作'" />
+                    </Dropdown>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </UiTable>
+        </div>
+        <div v-if="loading || !visible.length" class="empty-state">
+          {{ loading ? '加载中…' : '暂无符合条件的站点,可点击“新增站点”建立层级' }}
+        </div>
+        <Pagination
+          :current="page"
+          :total-pages="totalPages"
+          :page-size="pageSize"
+          @pageChange="(p) => (page = p)"
+          @pageSizeChange="(s) => { pageSize = s; page = 1; }"
+        />
+      </section>
+    </UiTabbedRegion>
+
+    <Drawer
+      class="station-info-drawer"
+      :width="720"
+      :title="draft?.id ? '编辑站点基本信息' : '新增站点'"
+      :open="!!draft"
+      :onClose="() => { if (!saving) draft = null; }"
+    >
+      <template #footer>
+        <UiActions class="station-drawer-actions">
+          <Button :disabled="saving" @click="() => (draft = null)">取消</Button>
+          <Button v-if="!draft?.id" :disabled="saving" @click="() => submit(true)">创建并继续新增</Button>
+          <Button type="primary" :loading="saving" @click="() => submit()">{{ draft?.id ? '保存' : '创建站点' }}</Button>
+        </UiActions>
+      </template>
+      <template v-if="draft">
+        <p class="station-drawer-note">此处只建立站点名称、基本属性和层级,内部配置稍后在“配置站点”中完成。</p>
+        <div class="station-basic-fields">
+          <label>
+            <span>上级站点</span>
+            <UiSelect
+              aria-label="上级站点"
+              :value="draft.parent_id || ''"
+              @change="(e) => (draft = { ...draft, parent_id: e.target.value ? Number(e.target.value) : null })"
+            >
+              <option value="">无,作为顶级站点</option>
+              <option
+                v-for="s in sites.filter((x) => !draft.id || !hasAncestor(sites, x.id, draft.id))"
+                :key="s.id"
+                :value="s.id"
+              >{{ sitePaths(sites, s.id).join(' / ') }}</option>
+            </UiSelect>
+          </label>
+          <label>
+            <span><i>*</i> 站点名称</span>
+            <UiInput
+              aria-label="站点名称"
+              :value="draft.name"
+              :maxLength="20"
+              placeholder="例如:北京市自来水厂 / 朝阳区 / 某乡镇站点"
+              @change="(e) => (draft = { ...draft, name: e.target.value })"
+            />
+          </label>
+          <label>
+            <span><i>*</i> 所属领域</span>
+            <UiSelect
+              aria-label="站点所属领域"
+              :value="draft.domain"
+              @change="(e) => (draft = { ...draft, domain: e.target.value, template_id: '' })"
+            >
+              <option value="">请选择所属领域</option>
+              <option v-for="item in domains" :key="item.id">{{ item.name }}</option>
+            </UiSelect>
+          </label>
+          <label>
+            <span>基本情况</span>
+            <UiInput
+              textarea
+              aria-label="站点基本情况"
+              :rows="3"
+              :value="draft.description"
+              placeholder="填写站点用途、管理范围或位置等基本属性"
+              @change="(e) => (draft = { ...draft, description: e.target.value })"
+            />
+          </label>
+        </div>
+        <section class="station-create-template">
+          <h3>站点模板</h3>
+          <div class="station-template-modes">
+            <label>
+              <UiInput
+                type="radio"
+                :checked="draft.mode === 'template'"
+                :disabled="!!draft.template_applied_at"
+                @change="() => (draft = { ...draft, mode: 'template' })"
+              /> 从模板建立站点
+            </label>
+            <label>
+              <UiInput
+                type="radio"
+                :checked="draft.mode === 'blank'"
+                :disabled="!!draft.template_applied_at"
+                @change="() => (draft = { ...draft, mode: 'blank' })"
+              /> 建立空白站点
+            </label>
+          </div>
+          <template v-if="draft.mode === 'template'">
+            <UiSelect
+              aria-label="选择站点模板"
+              :value="draft.template_id"
+              :disabled="!!draft.template_applied_at"
+              @change="(e) => (draft = { ...draft, template_id: e.target.value })"
+            >
+              <option value="">请选择领域站点模板</option>
+              <option v-for="template in draftTemplates" :key="template.id" :value="template.id">
+                {{ template.name }} · {{ template.version }}
+              </option>
+            </UiSelect>
+            <div v-if="selectedTemplate" class="station-selected-template">
+              <b>{{ selectedTemplate.name }}</b>
+              <p>{{ selectedTemplate.description || '使用该领域已积累的资产、页面与菜单配置' }}</p>
+              <span>{{ selectedTemplate.device_count || 0 }} 类设备模板 · {{ selectedTemplate.page_count || 0 }} 个领域页面</span>
+            </div>
+            <p class="station-template-footnote">
+              {{ draft.template_applied_at
+                ? '模板已应用;如需更换,请进入站点配置。'
+                : '这里只记录模板选择,进入站点配置后再应用,不影响继续建立子站点。' }}
+            </p>
+          </template>
+        </section>
+      </template>
+    </Drawer>
+  </div>
+</template>
diff --git a/src-vue/pages/Result.vue b/src-vue/pages/Result.vue
new file mode 100644 (file)
index 0000000..00bf34d
--- /dev/null
@@ -0,0 +1,167 @@
+<script setup>
+import { computed, h } from "vue";
+import { Button, Dropdown } from "ant-design-vue";
+import {
+  DownloadOutlined,
+  EditOutlined,
+  EllipsisOutlined,
+  EyeOutlined,
+} from "@ant-design/icons-vue";
+import Panel from "../components/Panel.vue";
+import StatusTag from "../components/StatusTag.vue";
+import UiActions from "../ui/UiActions.vue";
+import DashboardThumb from "../components/DashboardThumb.vue";
+import { fetchDb, downloadBlob } from "../lib/api.js";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  go: { type: Function, default: () => {} },
+  setModal: { type: Function, default: () => {} },
+  viewPrompt: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+  openFeedback: { type: Function, default: () => {} },
+  viewSitePrompt: { type: Function, default: () => {} },
+});
+
+const kpis = computed(() => {
+  const s = props.site;
+  if (!s) return [];
+  const generated = (s.cubes || []).filter((c) => c.status === "generated");
+  return [
+    [s.assets.filter((a) => a.type).length, "Asset 资产", "类设备"],
+    [s.mu.length, "MU 页面", "已配置"],
+    [
+      generated.length,
+      "Cube 页面",
+      generated.length === s.cubes.length ? "校验全部通过" : "部分未生成",
+    ],
+    [generated.length + 1, "交付文件", "JSON+ZIP"],
+  ];
+});
+
+const actionMenu = {
+  items: [
+    { key: "history", label: "版本历史" },
+    { key: "prompt", label: "查看站点提示词" },
+  ],
+  onClick: ({ key }) => {
+    if (key === "history") props.setModal("history");
+    else props.viewSitePrompt();
+  },
+};
+
+const cubeMenu = (cube) => ({
+  items: [
+    { key: "json", label: "下载 JSON", icon: h(DownloadOutlined) },
+    { key: "prompt", label: "查看提示词" },
+    ...(cube.gen_file_id
+      ? [{ key: "feedback", label: "提交修改意见", icon: h(EditOutlined) }]
+      : []),
+  ],
+  onClick: async ({ key }) => {
+    if (key === "json") {
+      const r = await fetch(`/api/db/sites/${props.site.id}/cubes/${cube.id}/file`);
+      const b = await r.blob();
+      downloadBlob(b, cube.gen_filename || `${cube.name}.json`);
+    } else if (key === "prompt") {
+      props.viewPrompt(cube.id, cube.name, cube.content);
+    } else if (key === "feedback") {
+      props.openFeedback(cube);
+    }
+  },
+});
+
+const downloadAll = () => {
+  window.open(`/api/db/sites/${props.site.id}/package`, "_blank");
+};
+
+const openPreview = async (cube) => {
+  if (!cube.id) return;
+  try {
+    const json = await fetchDb(`/sites/${props.site.id}/cubes/${cube.id}/file`);
+    const safe = JSON.stringify(json).replace(/</g, "\\u003c");
+    const origin = window.location.origin;
+    // Build the embedded HTML <script> tags with a runtime "<" so the SFC
+    // compiler never sees a literal opening/closing script tag in this source.
+    const LT = String.fromCharCode(60);
+    const st = LT + "script";
+    const et = LT + "/script";
+    const html = [
+      '<!doctype html><html><head><meta charset="utf-8"><title>',
+      cube.name || "看板预览",
+      '</title><style>html,body{margin:0;background:#fff;font-family:Arial,"Microsoft YaHei",sans-serif}</style></head><body><div id="root"></div>',
+      st + ' src="' + origin + '/t/stationbuilder/echarts.min.js">' + et,
+      st + ' src="' + origin + '/t/stationbuilder/dashboard-renderer.js">' + et,
+      st + ">renderDashboard(" + safe + ", document.getElementById('root'), {fit:true});" + et,
+      "</body></html>",
+    ].join("");
+    const blob = new Blob([html], { type: "text/html" });
+    const url = URL.createObjectURL(blob);
+    window.open(url, "_blank");
+    setTimeout(() => URL.revokeObjectURL(url), 60000);
+  } catch (e) {
+    props.notify("预览打开失败:" + (e?.message || e));
+  }
+};
+</script>
+
+<template>
+  <Panel v-if="!site"><div class="empty-state">加载中…</div></Panel>
+  <template v-else>
+    <div class="result-header">
+      <div>
+        <span class="site-icon big">S</span>
+        <section>
+          <h2>{{ site.name || "未命名站点" }}</h2>
+          <p>
+            {{ site.status === "generated" ? "生成完成" : "草稿" }} · {{ site.updated_at }}
+          </p>
+        </section>
+        <StatusTag>{{ site.status === "generated" ? "生成完成" : "草稿" }}</StatusTag>
+      </div>
+      <UiActions class="result-actions">
+        <Button class="button secondary" @click="() => go('assets')"><EditOutlined /> 修改配置</Button>
+        <Button type="primary" class="button" @click="downloadAll">
+          <DownloadOutlined /> 下载全部
+        </Button>
+        <Dropdown :trigger="['click']" :menu="actionMenu">
+          <Button class="button secondary"><EllipsisOutlined /> 更多</Button>
+        </Dropdown>
+      </UiActions>
+    </div>
+
+    <div class="kpi-row">
+      <div v-for="x in kpis" :key="x[1]">
+        <span>{{ x[1] }}</span>
+        <b>{{ x[0] }}</b>
+        <small>{{ x[2] }}</small>
+      </div>
+    </div>
+
+    <div class="result-layout">
+      <div>
+        <Panel title="页面结果">
+          <div class="result-pages">
+            <article v-for="(cube, i) in site.cubes" :key="i">
+              <DashboardThumb :site-id="site.id" :cube="cube" />
+              <div>
+                <header>
+                  <StatusTag>{{ cube.status === "generated" ? "校验通过" : "未生成" }}</StatusTag>
+                  <small>Cube 页面</small>
+                </header>
+                <h3>{{ cube.name }}</h3>
+                <p>{{ cube.content || "无描述" }}</p>
+                <footer>
+                  <Button class="button secondary" @click="openPreview(cube)"><EyeOutlined /> 预览</Button>
+                  <Dropdown v-if="cube.id" :trigger="['click']" :menu="cubeMenu(cube)">
+                    <Button class="button secondary"><EllipsisOutlined /> 更多</Button>
+                  </Dropdown>
+                </footer>
+              </div>
+            </article>
+          </div>
+        </Panel>
+      </div>
+    </div>
+  </template>
+</template>
diff --git a/src-vue/pages/SiteConfigurationContext.vue b/src-vue/pages/SiteConfigurationContext.vue
new file mode 100644 (file)
index 0000000..4dfba62
--- /dev/null
@@ -0,0 +1,35 @@
+<script setup>
+import { computed } from "vue";
+import { Tag } from "ant-design-vue";
+import UiButton from "../ui/UiButton.vue";
+
+const props = defineProps({
+  site: { type: Object, default: null },
+  onBack: { type: Function, default: null },
+});
+
+const hasAssets = computed(() =>
+  !!(props.site?.assets?.some((a) => a.type) || props.site?.cubes?.length || props.site?.mu?.length)
+);
+const tagColor = computed(() => (props.site?.template_applied_at ? "success" : "default"));
+const tagText = computed(() => {
+  const s = props.site;
+  if (!s) return "";
+  if (s.template_applied_at) return "已应用";
+  if (s.template_id) return "待应用";
+  if (hasAssets.value) return "现有配置";
+  return "空白配置";
+});
+</script>
+
+<template>
+  <div v-if="site" class="station-config-context">
+    <div>
+      <UiButton className="link" @click="onBack && onBack()">← 返回站点管理</UiButton>
+    </div>
+    <div>
+      <span>站点模板</span><b>{{ site.template_name || "未选择" }}</b>
+      <Tag :color="tagColor">{{ tagText }}</Tag>
+    </div>
+  </div>
+</template>
diff --git a/src-vue/pages/StyleLibrary.vue b/src-vue/pages/StyleLibrary.vue
new file mode 100644 (file)
index 0000000..a4e8ee1
--- /dev/null
@@ -0,0 +1,145 @@
+<script setup>
+import { ref, computed, watch, h } from "vue";
+import { Button as AntButton, Dropdown, Drawer } from "ant-design-vue";
+import { EditOutlined, DeleteOutlined, EllipsisOutlined, EyeOutlined } from "@ant-design/icons-vue";
+import UiButton from "../ui/UiButton.vue";
+import UiTable from "../ui/UiTable.vue";
+import UiActions from "../ui/UiActions.vue";
+import Panel from "../components/Panel.vue";
+import SearchInput from "../components/SearchInput.vue";
+import Pagination from "../components/Pagination.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+import StyleEditorModal from "../pages/domain/StyleEditorModal.vue";
+import { fetchDb } from "../lib/api.js";
+import { useConfirm } from "../pages/domain/useConfirm.js";
+
+const props = defineProps({
+  pageStyles: { type: Array, default: () => [] },
+  loadPageStyles: { type: Function, default: async () => {} },
+  notify: { type: Function, default: () => {} },
+});
+
+const query = ref("");
+const preview = ref(null);
+const editor = ref({ open: false, id: undefined });
+const pageIndex = ref(1);
+const pageSize = ref(10);
+const { confirm, el: confirmEl } = useConfirm();
+
+const filtered = computed(() =>
+  props.pageStyles.filter(
+    (row) =>
+      !query.value.trim() ||
+      (row.name || "").toLowerCase().includes(query.value.trim().toLowerCase())
+  )
+);
+watch(query, () => {
+  pageIndex.value = 1;
+});
+
+async function afterChange(msg) {
+  props.notify(msg);
+  editor.value = { open: false, id: undefined };
+  preview.value = null;
+  await props.loadPageStyles();
+}
+async function remove(row) {
+  if (!(await confirm(`确定删除页面样式「${row.name}」?此操作不可恢复`))) return;
+  try {
+    await fetchDb(`/page-styles/${row.id}`, { method: "DELETE" });
+    await afterChange("页面样式已删除");
+  } catch (e) {
+    props.notify("删除失败:" + (e && e.message ? e.message : e));
+  }
+}
+function rowMenu(row) {
+  return {
+    items: [
+      { key: "edit", label: "编辑样式", icon: h(EditOutlined) },
+      { key: "delete", label: "删除", danger: true, icon: h(DeleteOutlined) },
+    ],
+    onClick: ({ key }) =>
+      key === "edit"
+        ? (editor.value = { open: true, id: row.id })
+        : remove(row),
+  };
+}
+</script>
+
+<template>
+  <component :is="confirmEl" />
+  <Panel class="company-list-panel resource-list-panel style-resource-panel">
+    <div class="filterbar company-filterbar">
+      <SearchInput :value="query" placeholder="搜索样式名称" @change="(v) => (query = v)" />
+      <UiActions class="filter-actions">
+        <PrimaryButton secondary @click="query = ''">重置</PrimaryButton>
+        <PrimaryButton @click="editor = { open: true }">+ 新增样式</PrimaryButton>
+      </UiActions>
+    </div>
+    <UiTable class="data-table company-table resource-table style-resource-table">
+      <thead>
+        <tr>
+          <th>样式方案名称</th>
+          <th>缩略图</th>
+          <th>创建时间</th>
+          <th>预览</th>
+          <th>操作</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="row in filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize)"
+          :key="row.id"
+          @click="preview = row"
+        >
+          <td>
+            <UiButton class="link company-record-link" :title="row.name" @click.stop="preview = row">
+              <b>{{ row.name || "未命名页面样式" }}</b>
+            </UiButton>
+          </td>
+          <td>
+            <img class="style-list-thumbnail" :src="`/api/db/page-styles/${row.id}/image?w=120`" :alt="`${row.name}缩略图`" />
+          </td>
+          <td>{{ row.created_at || "—" }}</td>
+          <td>
+            <AntButton type="link" :icon="h(EyeOutlined)" @click.stop="preview = row">查看预览</AntButton>
+          </td>
+          <td @click.stop>
+            <Dropdown :trigger="['click']" :menu="rowMenu(row)">
+              <AntButton type="text" aria-label="更多操作" :icon="h(EllipsisOutlined)" />
+            </Dropdown>
+          </td>
+        </tr>
+      </tbody>
+    </UiTable>
+    <div v-if="filtered.length === 0" class="empty-state">暂无符合条件的页面样式</div>
+    <Pagination
+      :current="pageIndex"
+      :total-pages="Math.max(1, Math.ceil(filtered.length / pageSize))"
+      :page-size="pageSize"
+      @page-change="(p) => (pageIndex = p)"
+      @page-size-change="(s) => { pageSize = s; pageIndex = 1; }"
+    />
+  </Panel>
+  <Drawer :title="preview?.name || '页面样式预览'" :width="560" :open="!!preview" @close="preview = null">
+    <template #extra>
+      <PrimaryButton v-if="preview" @click="() => { editor = { open: true, id: preview.id }; preview = null; }">编辑样式</PrimaryButton>
+    </template>
+    <div v-if="preview" class="style-drawer">
+      <img :src="`/api/db/page-styles/${preview.id}/image`" :alt="preview.name" />
+      <dl>
+        <dt>样式名称</dt>
+        <dd>{{ preview.name }}</dd>
+        <dt>创建时间</dt>
+        <dd>{{ preview.created_at || "—" }}</dd>
+      </dl>
+    </div>
+  </Drawer>
+  <StyleEditorModal
+    v-if="editor.open"
+    :id="editor.id"
+    :notify="notify"
+    :after-change="afterChange"
+    :on-close="() => (editor = { open: false, id: undefined })"
+  />
+</template>
diff --git a/src-vue/pages/ToolSettings.vue b/src-vue/pages/ToolSettings.vue
new file mode 100644 (file)
index 0000000..bd6d894
--- /dev/null
@@ -0,0 +1,51 @@
+<script setup>
+import { ref, watch } from "vue";
+import UiInput from "../ui/UiInput.vue";
+import UiSelect from "../ui/UiSelect.vue";
+import UiActions from "../ui/UiActions.vue";
+import Panel from "../components/Panel.vue";
+import Field from "../components/Field.vue";
+import PrimaryButton from "../components/PrimaryButton.vue";
+import { MODELS } from "../lib/meta.js";
+
+// React `Button` maps to PrimaryButton (AntButton primary/default + "button" class).
+const Button = PrimaryButton;
+
+const props = defineProps({
+  model: { type: String, default: "" },
+  onModelChange: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+});
+
+const draftModel = ref(props.model);
+watch(
+  () => props.model,
+  (v) => (draftModel.value = v)
+);
+
+const freeModels = MODELS.filter((item) => item.rate === "0.00x");
+</script>
+
+<template>
+  <div class="system-settings-page">
+    <Panel title="内部工具配置">
+      <div class="settings-form">
+        <Field label="工具名称"><UiInput value="StationBuilder" :readonly="true" /></Field>
+        <Field label="内部 AI 服务"><UiInput value="Wordbuddy AI" :readonly="true" /></Field>
+        <Field label="默认模型" hint="当前仅开放免费模型">
+          <UiSelect :value="draftModel" @change="(e) => (draftModel = e.target.value)">
+            <option v-for="item in freeModels" :key="item.name" :value="item.name">{{ item.name }} · {{ item.tag || "免费" }}</option>
+          </UiSelect>
+        </Field>
+        <Field label="工具版本"><UiInput value="v0.4" :readonly="true" /></Field>
+        <div class="settings-note">
+          <strong>配置说明</strong>
+          <span>此处集中维护原侧栏底部的内部工具配置。保存后,新建的 AI 会话将使用所选默认模型。</span>
+        </div>
+        <UiActions className="settings-actions">
+          <Button @click="() => { onModelChange(draftModel); notify('内部工具配置已保存'); }">保存配置</Button>
+        </UiActions>
+      </div>
+    </Panel>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/CompanyDomainLibrary.vue b/src-vue/pages/domain/CompanyDomainLibrary.vue
new file mode 100644 (file)
index 0000000..13b4188
--- /dev/null
@@ -0,0 +1,127 @@
+<script setup>
+import { ref, computed, watch, onMounted } from "vue";
+import { Tag } from "ant-design-vue";
+import {
+  SearchOutlined,
+  ApartmentOutlined,
+  BlockOutlined,
+  SnippetsOutlined,
+  BarsOutlined,
+} from "@ant-design/icons-vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiInput from "../../ui/UiInput.vue";
+import UiSelect from "../../ui/UiSelect.vue";
+import { fetchDb, domainTagColor } from "../../lib/api.js";
+import { friendlyDomainName, domainScene, SOLUTION_PHOTOS, statusTone } from "./domainCommon.js";
+
+const props = defineProps({
+  go: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+  onOpen: { type: Function, default: () => {} },
+  domainFilter: { type: Number, default: null },
+  domainOptions: { type: Array, default: () => [] },
+});
+
+const list = ref([]);
+const loading = ref(true);
+const query = ref("");
+const domainSel = ref(props.domainFilter == null ? "" : String(props.domainFilter));
+
+const load = async () => {
+  try {
+    list.value = (await fetchDb("/domain-templates")) || [];
+  } catch (e) {
+    notify("加载失败:" + (e && e.message ? e.message : e));
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(load);
+watch(
+  () => props.domainFilter,
+  (f) => {
+    domainSel.value = f == null ? "" : String(f);
+  }
+);
+
+const filtered = computed(() =>
+  list.value.filter(
+    (row) =>
+      (!domainSel.value || String(row.domain_id) === domainSel.value) &&
+      (!query.value.trim() ||
+        `${row.name} ${row.domain_name} ${row.description || ""}`
+          .toLowerCase()
+          .includes(query.value.trim().toLowerCase()))
+  )
+);
+
+const totals = computed(() => ({
+  assets: list.value.reduce((n, x) => n + Number(x.device_count || 0), 0),
+  pages: list.value.reduce((n, x) => n + Number(x.page_count || 0), 0),
+  docs: list.value.reduce((n, x) => n + Number(x.doc_count || 0), 0),
+}));
+
+const statusText = (row) => (row.status === "available" ? "可用" : "不可用");
+</script>
+
+<template>
+  <div class="domain-showcase-page">
+    <section class="domain-showcase-intro">
+      <div><span>已沉淀领域</span><b>{{ list.length }}</b></div>
+      <p>领域方案用于展示和复用成熟场站能力。每个方案都由资产、页面、知识和菜单四部分组成,可分别查看和下载。</p>
+      <div class="domain-showcase-total">
+        <span>设备模板 <b>{{ totals.assets }}</b></span>
+        <span>仪表板 <b>{{ totals.pages }}</b></span>
+        <span>知识文档 <b>{{ totals.docs }}</b></span>
+      </div>
+    </section>
+    <div class="domain-showcase-filter">
+      <label>
+        <SearchOutlined />
+        <UiInput :value="query" @change="(e) => (query = e.target.value)" placeholder="搜索领域或方案名称" />
+      </label>
+      <UiSelect :value="domainSel" @change="(e) => (domainSel = e.target.value)">
+        <option value="">全部领域</option>
+        <option v-for="d in domainOptions" :key="d.id" :value="d.id">{{ friendlyDomainName(d.name) }}</option>
+      </UiSelect>
+      <span>共 {{ filtered.length }} 个方案</span>
+    </div>
+    <div v-if="loading" class="empty-state">正在加载领域方案…</div>
+    <div v-else class="domain-showcase-grid">
+      <article
+        v-for="row in filtered"
+        :key="row.id"
+        :class="['domain-showcase-card', domainScene(row.domain_name || '').tone]"
+        @click="() => onOpen(row.id)"
+      >
+        <header>
+          <div class="domain-showcase-mark">{{ domainScene(row.domain_name || "").code }}</div>
+          <div>
+            <Tag class="tag-new" :color="domainTagColor(friendlyDomainName(row.domain_name))">{{ friendlyDomainName(row.domain_name) }}</Tag>
+            <Tag class="status" :class="statusTone(statusText(row))">{{ statusText(row) }}</Tag>
+          </div>
+        </header>
+        <h2>{{ row.name || "未命名领域方案" }}</h2>
+        <p>{{ row.description || domainScene(row.domain_name || "").desc }}</p>
+        <img
+          v-if="SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
+          class="domain-showcase-photo"
+          :src="SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
+          :alt="row.name || domainScene(row.domain_name || '').code"
+        />
+        <div class="domain-showcase-capabilities">
+          <span><ApartmentOutlined /><b>资产</b><small>{{ row.device_count || 0 }} 类设备</small></span>
+          <span><BlockOutlined /><b>页面</b><small>{{ row.page_count || 0 }} 仪表板 + SC</small></span>
+          <span><SnippetsOutlined /><b>知识</b><small>{{ row.doc_count || 0 }} 文档 + 百科</small></span>
+          <span><BarsOutlined /><b>菜单</b><small>标准结构</small></span>
+        </div>
+        <footer>
+          <span>版本 {{ row.version || "1.0" }}</span>
+          <UiButton @click.stop="() => onOpen(row.id)">查看方案 <span>→</span></UiButton>
+        </footer>
+      </article>
+    </div>
+    <div v-if="!loading && filtered.length === 0" class="empty-state">暂无符合条件的领域方案,可通过右上角导入。</div>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/DeviceImageField.vue b/src-vue/pages/domain/DeviceImageField.vue
new file mode 100644 (file)
index 0000000..44a6f80
--- /dev/null
@@ -0,0 +1,24 @@
+<script setup>
+import { ref } from "vue";
+
+const props = defineProps({
+  imageUrl: { type: String, default: null },
+  onPick: { type: Function, default: () => {} },
+});
+
+const inputRef = ref(null);
+
+function onChange(e) {
+  const f = e.target.files && e.target.files[0];
+  if (f) props.onPick(f);
+  e.target.value = "";
+}
+</script>
+
+<template>
+  <div class="device-img-field" @click="() => inputRef && inputRef.click()">
+    <img v-if="imageUrl" :src="imageUrl" alt="设备图片" />
+    <span v-else class="device-img-empty">+ 点击上传设备图片</span>
+    <input ref="inputRef" type="file" accept="image/*" hidden @change="onChange" />
+  </div>
+</template>
diff --git a/src-vue/pages/domain/DeviceTemplateCreate.vue b/src-vue/pages/domain/DeviceTemplateCreate.vue
new file mode 100644 (file)
index 0000000..d9b3d9a
--- /dev/null
@@ -0,0 +1,68 @@
+<script setup>
+import { ref } from "vue";
+import Panel from "../../components/Panel.vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiInput from "../../ui/UiInput.vue";
+import Button from "../../components/PrimaryButton.vue";
+import DeviceImageField from "./DeviceImageField.vue";
+import { fetchDb } from "../../lib/api.js";
+
+const props = defineProps({
+  notify: { type: Function, default: () => {} },
+  onDone: { type: Function, default: () => {} },
+  onCancel: { type: Function, default: () => {} },
+});
+
+const name = ref("");
+const description = ref("");
+const imgPreview = ref(null);
+const imgB64 = ref(null);
+
+function readBase64(file) {
+  return new Promise((resolve, reject) => {
+    const r = new FileReader();
+    r.onload = () => resolve({ filename: file.name, mime: file.type || "application/octet-stream", data: String(r.result).split(",")[1] });
+    r.onerror = () => reject(r.error);
+    r.readAsDataURL(file);
+  });
+}
+
+async function onPick(f) {
+  imgPreview.value = URL.createObjectURL(f);
+  imgB64.value = await readBase64(f);
+}
+
+async function add() {
+  if (!name.value.trim()) {
+    props.notify("请填写设备名称");
+    return;
+  }
+  try {
+    const body = { name: name.value, description: description.value };
+    if (imgB64.value) body.image = imgB64.value;
+    const r = await fetchDb("/device-templates", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(body),
+    });
+    props.notify("已创建设备模版");
+    props.onDone(r.id);
+  } catch (e) {
+    props.notify("创建失败:" + (e && e.message ? e.message : e));
+  }
+}
+</script>
+
+<template>
+  <Panel title="新增设备模版">
+    <template #tools>
+      <UiButton class="text-button" @click="onCancel">取消</UiButton>
+    </template>
+    <div class="form-grid">
+      <label>设备名称<UiInput :value="name" @change="(e) => (name = e.target.value)" placeholder="如:PLC 控制器" /></label>
+      <label style="grid-column: 1 / -1">设备图片<DeviceImageField :image-url="imgPreview" :on-pick="onPick" /></label>
+      <label style="grid-column: 1 / -1">说明<UiInput textarea :rows="2" :value="description" @change="(e) => (description = e.target.value)" /></label>
+      <div style="grid-column: 1 / -1"><Button @click="add">创建</Button></div>
+    </div>
+  </Panel>
+</template>
diff --git a/src-vue/pages/domain/DomainCapabilityDetail.vue b/src-vue/pages/domain/DomainCapabilityDetail.vue
new file mode 100644 (file)
index 0000000..9e2e144
--- /dev/null
@@ -0,0 +1,394 @@
+<script setup>
+import { ref, computed, watch, onMounted } from "vue";
+import { Tag } from "ant-design-vue";
+import {
+  ApartmentOutlined,
+  BlockOutlined,
+  AimOutlined,
+  SnippetsOutlined,
+  EllipsisOutlined,
+  UploadOutlined,
+  PlusOutlined,
+  EditOutlined,
+  DownloadOutlined,
+  LinkOutlined,
+  EyeOutlined,
+} from "@ant-design/icons-vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiSelect from "../../ui/UiSelect.vue";
+import UiTable from "../../ui/UiTable.vue";
+import UiActions from "../../ui/UiActions.vue";
+import UiOverlay from "../../ui/UiOverlay.vue";
+import Status from "../../components/StatusTag.vue";
+import Panel from "../../components/Panel.vue";
+import Field from "../../components/Field.vue";
+import Button from "../../components/PrimaryButton.vue";
+import DomainPageThumb from "../../components/DomainPageThumb.vue";
+import SearchInput from "../../components/SearchInput.vue";
+import { fetchDb, formatInstances, domainTagColor } from "../../lib/api.js";
+import { friendlyDomainName, domainScene, SOLUTION_PHOTOS } from "./domainCommon.js";
+
+const props = defineProps({
+  id: { type: Number, required: true },
+  doms: { type: Array, default: () => [] },
+  notify: { type: Function, default: () => {} },
+  onBack: { type: Function, default: () => {} },
+  onEditAssets: { type: Function, default: () => {} },
+  onOpenDeviceTemplate: { type: Function, default: () => {} },
+  onCreateSite: { type: Function, default: () => {} },
+});
+
+const detail = ref(null);
+const muTree = ref([]);
+const loading = ref(true);
+const preview = ref(null);
+const editing = ref(false);
+const dashboardEditorOpen = ref(false);
+const dashboardDomain = ref("");
+const dashboardQuery = ref("");
+const packageOpen = ref(false);
+const draft = ref(null);
+const creatingSite = ref(false);
+const creatingSiteRef = ref(false);
+
+const createFromTemplate = async () => {
+  if (creatingSiteRef.value) return;
+  creatingSiteRef.value = true;
+  creatingSite.value = true;
+  try {
+    await props.onCreateSite(props.id);
+  } catch (e) {
+    props.notify("根据模板创建站点失败:" + (e && e.message ? e.message : e));
+  } finally {
+    creatingSiteRef.value = false;
+    creatingSite.value = false;
+  }
+};
+
+async function load() {
+  loading.value = true;
+  try {
+    const [d, mt] = await Promise.all([fetchDb(`/domain-templates/${props.id}`), fetchDb("/mu-tree")]);
+    detail.value = d;
+    draft.value = {
+      domain_id: d.domain_id,
+      name: d.name || "",
+      version: d.version || "1.0",
+      status: d.status || "available",
+      description: d.description || "",
+    };
+    dashboardDomain.value = d.domain_name || props.doms.find((domain) => domain.id === d.domain_id)?.name || "";
+    muTree.value = mt || [];
+  } catch (e) {
+    props.notify("加载方案失败:" + (e && e.message ? e.message : e));
+  } finally {
+    loading.value = false;
+  }
+}
+onMounted(load);
+
+async function saveMeta() {
+  try {
+    await fetchDb(`/domain-templates/${props.id}`, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(draft.value),
+    });
+    props.notify("方案信息已保存");
+    editing.value = false;
+    await load();
+  } catch (e) {
+    props.notify("保存失败:" + (e && e.message ? e.message : e));
+  }
+}
+
+const rawDomain = computed(() => detail.value?.domain_name || props.doms.find((d) => d.id === detail.value?.domain_id)?.name || "");
+const domainName = computed(() => friendlyDomainName(rawDomain.value));
+const scene = computed(() => domainScene(rawDomain.value));
+const assets = computed(() => (detail.value?.assets || []).filter((a) => a.device_template_id || a.device_name));
+const pages = computed(() => (detail.value?.pages || []) );
+const extensionPages = computed(() =>
+  Array.from(
+    new Map(
+      pages.value
+        .filter((page) => {
+          const json = (page.json_text || "").trim();
+          return page.name.trim() !== "新页面" && json !== "" && json !== "{}" && json !== "null";
+        })
+        .map((page) => [page.name.trim(), page])
+    ).values()
+  )
+);
+const docs = computed(() => detail.value?.docs || []);
+const knowledgeItems = [
+  { type: "document", name: `${detail.value?.name || domainName.value}运行维护手册`, meta: "PDF · 2026-08-18" },
+  { type: "document", name: `${domainName.value}设备巡检规范`, meta: "DOCX · 2026-08-12" },
+  { type: "document", name: "异常报警与处置规则", meta: "PDF · 2026-07-30" },
+  { type: "document", name: "场站安全操作规程", meta: "PDF · 2026-07-16" },
+  { type: "link", name: `${domainName.value}行业标准查询`, meta: "外部链接 · 已验证" },
+  { type: "link", name: "设备厂商技术资料", meta: "外部链接 · 已验证" },
+];
+const knowledgeDocuments = computed(() => knowledgeItems.filter((item) => item.type === "document"));
+const knowledgeLinks = computed(() => knowledgeItems.filter((item) => item.type === "link"));
+const twinScenes = computed(() =>
+  (scene.value.twins.length ? scene.value.twins : [`${detail.value?.name || domainName.value}全景监控`, `${domainName.value}工艺流程场景`]).map(
+    (name, index) => ({ name, status: index === 0 ? "运行中" : "已配置" })
+  )
+);
+const download = (label) => props.notify(`${label}配置 ZIP 已准备(设计示意)`);
+const capabilityCards = [
+  { key: "assets", icon: ApartmentOutlined, title: "资产", subtitle: "设备列表与资产组结构", count: `${assets.value.length} 个类型` },
+  { key: "pages", icon: BlockOutlined, title: "领域扩展仪表板", subtitle: "领域仪表板页面", count: `${extensionPages.value.length} 个仪表板` },
+  { key: "twins", icon: AimOutlined, title: "数字孪生场景", subtitle: "SC 数字孪生场景画面", count: `${twinScenes.value.length} 个场景` },
+  { key: "knowledge", icon: SnippetsOutlined, title: "知识", subtitle: "领域文档与参考链接", count: `${knowledgeDocuments.value.length} 份文档 / ${knowledgeLinks.value.length} 个链接` },
+];
+const previewTitle = computed(() => capabilityCards.find((x) => x.key === preview.value)?.title || "");
+const visibleDashboards = computed(() =>
+  extensionPages.value.filter(
+    (page) => !dashboardQuery.value.trim() || `${page.name} ${page.description || ""}`.toLowerCase().includes(dashboardQuery.value.trim().toLowerCase())
+  )
+);
+
+function editHandler(card) {
+  if (card.key === "assets") return () => props.onEditAssets(rawDomain.value);
+  if (card.key === "pages") return () => (dashboardEditorOpen.value = true);
+  return undefined;
+}
+</script>
+
+<template>
+  <div v-if="loading || !detail" class="empty-state">正在加载领域能力…</div>
+  <div v-else class="domain-capability-detail">
+    <template v-if="dashboardEditorOpen">
+      <UiButton class="domain-capability-back" @click="() => (dashboardEditorOpen = false)">← 返回领域方案</UiButton>
+      <section class="dashboard-manager-panel">
+        <header class="dashboard-manager-toolbar">
+          <label>
+            <span>领域分类</span>
+            <UiSelect :value="dashboardDomain" @change="(event) => (dashboardDomain = event.target.value)">
+              <option v-for="domain in doms" :key="domain.id" :value="domain.name">{{ friendlyDomainName(domain.name) }}</option>
+            </UiSelect>
+          </label>
+          <UiActions class="dashboard-manager-actions">
+            <Button secondary @click="() => notify('导入功能将在后续实现')"><UploadOutlined /> 导入</Button>
+            <Button @click="() => notify('新增功能将在后续实现')"><PlusOutlined /> 新增</Button>
+            <SearchInput :value="dashboardQuery" @change="(v) => (dashboardQuery = v)" placeholder="请输入仪表板名称" />
+          </UiActions>
+        </header>
+        <div class="dashboard-manager-grid">
+          <article v-for="(page, index) in visibleDashboards" :key="page.id">
+            <UiButton class="dashboard-card-more" type="button" aria-label="`${page.name}更多操作`"><EllipsisOutlined /></UiButton>
+            <h3>{{ page.name }}</h3>
+            <p>更新时间:2026-{{ String((index % 8) + 1).padStart(2, "0") }}-{{ String((index % 21) + 5).padStart(2, "0") }} {{ String(9 + (index % 8)).padStart(2, "0") }}:30:00</p>
+            <Status v-if="page.status !== 'generated'">未发布</Status>
+          </article>
+        </div>
+        <div v-if="visibleDashboards.length === 0" class="empty-state">当前筛选条件下暂无仪表板</div>
+      </section>
+    </template>
+
+    <template v-else>
+      <UiButton class="domain-capability-back" @click="onBack">← 返回领域方案</UiButton>
+      <section :class="['domain-capability-head', scene.tone]">
+        <div class="domain-capability-code">{{ scene.code }}</div>
+        <div class="domain-capability-copy">
+          <div><Tag class="tag-new" :color="domainTagColor(domainName)">{{ domainName }}</Tag><Status>{{ detail.status === "available" ? "可用" : "不可用" }}</Status><span>版本 {{ detail.version || "1.0" }}</span></div>
+          <h2>{{ detail.name || "未命名领域方案" }}</h2>
+          <p>{{ detail.description || scene.desc }}</p>
+        </div>
+        <UiActions class="domain-capability-actions">
+          <Button secondary @click="() => (editing = true)"><EditOutlined /> 维护方案</Button>
+          <Button secondary @click="() => (packageOpen = true)"><DownloadOutlined /> 下载完整方案</Button>
+          <Button :disabled="creatingSite" @click="createFromTemplate" title="使用当前领域方案的基本信息、资产与标准页面配置创建草稿站点">
+            <PlusOutlined /> {{ creatingSite ? "正在创建站点…" : "根据模板创建领域站点" }}
+          </Button>
+        </UiActions>
+      </section>
+
+      <div class="domain-capability-summary">
+        <span><b>{{ assets.length }}</b>设备类型</span>
+        <span><b>{{ extensionPages.length }}</b>领域扩展仪表板</span>
+        <span><b>{{ twinScenes.length }}</b>数字孪生场景</span>
+        <span><b>{{ knowledgeItems.length }}</b>领域知识</span>
+      </div>
+
+      <div class="domain-capability-grid">
+        <section v-for="card in capabilityCards" :key="card.key" :class="['domain-capability-card', card.key]">
+          <header>
+            <div class="domain-capability-icon"><component :is="card.icon" /></div>
+            <div><h3>{{ card.title }}</h3><p>{{ card.subtitle }}</p></div>
+            <strong>{{ card.count }}</strong>
+          </header>
+
+          <div v-if="card.key === 'assets'" class="domain-capability-body capability-asset-content">
+            <div class="capability-section-title"><b>设备模板</b><span>{{ assets.length }} 个</span></div>
+            <div class="device-template-grid">
+              <div
+                v-for="(asset, index) in assets"
+                :key="`${asset.device_template_id}-${index}`"
+                class="device-template-tile"
+                :title="asset.device_name || '设备模板'"
+                @click="(e) => { e.stopPropagation(); if (asset.device_template_id) props.onOpenDeviceTemplate(asset.device_template_id); }"
+              >
+                <span><BlockOutlined /></span><b>{{ asset.device_name || "设备模板" }}</b>
+              </div>
+            </div>
+            <div v-if="assets.length === 0" class="device-template-empty">当前领域暂未关联设备模板</div>
+          </div>
+
+          <div v-else-if="card.key === 'pages'" class="domain-capability-body capability-page-content">
+            <div class="capability-section-title"><b>领域扩展仪表板</b><span>{{ extensionPages.length }} 个</span></div>
+            <div class="extension-page-grid">
+              <article v-for="page in extensionPages" :key="page.id" class="extension-page-tile" :title="page.name">
+                <DomainPageThumb :json-text="page.json_text" empty-label="页面缩略图" fit :fixed-height="92" />
+                <b>{{ page.name }}</b>
+              </article>
+            </div>
+            <div v-if="extensionPages.length === 0" class="device-template-empty">当前领域暂未配置扩展仪表板</div>
+          </div>
+
+          <div v-else-if="card.key === 'twins'" class="domain-capability-body capability-twin-content">
+            <div class="capability-section-title"><b>SC 数字孪生场景</b><span>{{ twinScenes.length }} 个</span></div>
+            <div class="twin-scene-card-list">
+              <article v-for="(item, index) in twinScenes" :key="item.name" class="twin-scene-card">
+                <div :class="['sc-scene', 'scene-' + index]"><i /><i /><i /><i /><span>SC 实时场景</span></div>
+                <div><b>{{ item.name }}</b><small>{{ item.status }}</small></div>
+              </article>
+            </div>
+          </div>
+
+          <div v-else-if="card.key === 'knowledge'" class="domain-capability-body capability-knowledge-content">
+            <div class="knowledge-group">
+              <div class="capability-section-title"><b>文档</b><span>{{ knowledgeDocuments.length }} 份</span></div>
+              <div class="knowledge-item-list">
+                <div v-for="item in knowledgeDocuments" :key="item.name" class="knowledge-item">
+                  <span class="knowledge-type document"><SnippetsOutlined /></span>
+                  <div><b>{{ item.name }}</b><small>{{ item.meta }}</small></div>
+                </div>
+              </div>
+            </div>
+            <div class="knowledge-group">
+              <div class="capability-section-title"><b>链接</b><span>{{ knowledgeLinks.length }} 个</span></div>
+              <div class="knowledge-item-list">
+                <div v-for="item in knowledgeLinks" :key="item.name" class="knowledge-item">
+                  <span class="knowledge-type link"><LinkOutlined /></span>
+                  <div><b>{{ item.name }}</b><small>{{ item.meta }}</small></div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div v-else class="domain-capability-body capability-empty-slot" :aria-label="`${card.title}内容占位`" />
+
+          <footer>
+            <UiButton type="button" class="capability-edit-button" @click="editHandler(card)"><EditOutlined /> 编辑</UiButton>
+            <UiButton @click="() => (preview = card.key)"><EyeOutlined /> 预览</UiButton>
+            <UiButton @click="() => download(card.title)"><DownloadOutlined /> 下载配置 ZIP</UiButton>
+          </footer>
+        </section>
+      </div>
+    </template>
+
+    <UiOverlay v-if="preview" class="modal-backdrop" @mousedown="() => (preview = null)">
+      <div class="modal capability-preview-modal" @mousedown.stop>
+        <header><h2>{{ previewTitle }}能力预览</h2><UiButton @click="() => (preview = null)">×</UiButton></header>
+        <div class="modal-body">
+          <div v-if="preview === 'assets'" class="capability-assets-preview">
+            <section>
+              <h3>设备列表</h3>
+              <UiTable class="data-table">
+                <thead><tr><th>设备模板</th><th>典型数量</th><th>命名规则</th></tr></thead>
+                <tbody>
+                  <tr v-for="(a, i) in assets" :key="i"><td>{{ a.device_name || "设备模板" }}</td><td>{{ a.count || 0 }}</td><td>{{ a.rule || "—" }}</td></tr>
+                </tbody>
+              </UiTable>
+              <div v-if="assets.length === 0" class="empty-state">暂无设备配置</div>
+            </section>
+            <section>
+              <h3>资产组结构</h3>
+              <div class="capability-full-tree">
+                <b>{{ detail.name }}</b>
+                <div v-for="(a, i) in assets" :key="i"><span>{{ a.device_name || "设备" }}</span><small>{{ formatInstances(a.rule || "DEV-##", Math.min(Number(a.count || 0), 4)) || `${a.count || 0} 个实例` }}</small></div>
+              </div>
+            </section>
+          </div>
+          <div v-else-if="preview === 'pages'" class="capability-dashboard-grid">
+            <article v-for="p in extensionPages" :key="p.id">
+              <DomainPageThumb :json-text="p.json_text" empty-label="仪表板预览" />
+              <div><b>{{ p.name }}</b><span>{{ p.description || "场站运行指标与状态展示" }}</span></div>
+            </article>
+            <div v-if="extensionPages.length === 0" class="empty-state">暂无领域扩展仪表板</div>
+          </div>
+          <div v-else-if="preview === 'twins'" class="capability-twin-grid">
+            <article v-for="(item, index) in twinScenes" :key="item.name">
+              <div :class="['sc-scene', 'scene-' + index]"><i /><i /><i /><i /><span>SC 实时场景</span></div>
+              <div><b>{{ item.name }}</b><span>设备空间关系、运行状态与告警联动</span></div>
+            </article>
+          </div>
+          <div v-else-if="preview === 'knowledge'" class="capability-knowledge-preview">
+            <section>
+              <h3>领域百科</h3>
+              <div v-for="(name, i) in scene.wiki" :key="name"><SnippetsOutlined /><span><b>{{ name }}</b><small>{{ i === 0 ? "领域概念、工艺流程与管理指标" : "常用设备、参数与运行原理" }}</small></span></div>
+            </section>
+            <section>
+              <h3>文档资料</h3>
+              <template v-if="docs.length">
+                <div v-for="d in docs" :key="d.id"><span class="file-type">{{ d.doc_type === "link" ? "URL" : "DOC" }}</span><span><b>{{ d.name }}</b><small>{{ d.doc_type === "link" ? "参考站点" : "维修手册 / 规范 / 规则" }}</small></span></div>
+              </template>
+              <div v-else><span class="file-type">DOC</span><span><b>维修手册与报警处置规则</b><small>方案导入后可继续补充</small></span></div>
+            </section>
+          </div>
+        </div>
+        <footer>
+          <Button secondary @click="() => (preview = null)">关闭</Button>
+          <Button @click="() => { download(previewTitle); preview = null; }"><DownloadOutlined /> 下载配置 ZIP</Button>
+        </footer>
+      </div>
+    </UiOverlay>
+
+    <UiOverlay v-if="editing && draft" class="modal-backdrop" @mousedown="() => (editing = false)">
+      <div class="modal domain-maintain-modal" @mousedown.stop>
+        <header><h2>维护领域方案</h2><UiButton @click="() => (editing = false)">×</UiButton></header>
+        <div class="modal-body">
+          <div class="form-grid">
+            <Field label="方案名称" required><UiInput :value="draft.name" @change="(e) => (draft = { ...draft, name: e.target.value })" /></Field>
+            <Field label="所属领域" required>
+              <UiSelect :value="draft.domain_id" @change="(e) => (draft = { ...draft, domain_id: Number(e.target.value) })">
+                <option v-for="d in doms" :key="d.id" :value="d.id">{{ friendlyDomainName(d.name) }}</option>
+              </UiSelect>
+            </Field>
+            <Field label="版本"><UiInput :value="draft.version" @change="(e) => (draft = { ...draft, version: e.target.value })" /></Field>
+            <Field label="状态">
+              <UiSelect :value="draft.status" @change="(e) => (draft = { ...draft, status: e.target.value })">
+                <option value="available">可用</option>
+                <option value="unavailable">不可用</option>
+              </UiSelect>
+            </Field>
+            <Field label="方案说明"><UiInput textarea :rows="4" :value="draft.description" @change="(e) => (draft = { ...draft, description: e.target.value })" /></Field>
+          </div>
+          <div class="domain-maintain-note">资产、页面、知识与菜单均可分别进入预览后维护;本页不再采用分步确认流程。</div>
+        </div>
+        <footer>
+          <Button secondary @click="() => (editing = false)">取消</Button>
+          <Button @click="saveMeta">保存</Button>
+        </footer>
+      </div>
+    </UiOverlay>
+
+    <UiOverlay v-if="packageOpen" class="modal-backdrop" @mousedown="() => (packageOpen = false)">
+      <div class="modal capability-package-modal" @mousedown.stop>
+        <header><h2>下载完整领域方案</h2><UiButton @click="() => (packageOpen = false)">×</UiButton></header>
+        <div class="modal-body">
+          <p>完整方案包将包含以下四类可导入配置:</p>
+          <div>
+            <span v-for="card in capabilityCards" :key="card.key"><component :is="card.icon" /><b>{{ card.title }}</b><small>{{ card.subtitle }}</small></span>
+          </div>
+        </div>
+        <footer>
+          <Button secondary @click="() => (packageOpen = false)">取消</Button>
+          <Button @click="() => { download('完整领域方案'); packageOpen = false; }">确认下载 ZIP</Button>
+        </footer>
+      </div>
+    </UiOverlay>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/DomainDetail.vue b/src-vue/pages/domain/DomainDetail.vue
new file mode 100644 (file)
index 0000000..d9df74b
--- /dev/null
@@ -0,0 +1,48 @@
+<script setup>
+import { ref, onMounted } from "vue";
+import { fetchDb } from "../../lib/api.js";
+// NOTE: ImportDomainPackage (app/page.tsx:2619+) and DomainCapabilityDetail (app/page.tsx:2035+)
+// are defined OUTSIDE the assigned port range (1928-2034) and are referenced faithfully below.
+// They are expected to be ported in a subsequent task; this file is not imported by Home.vue yet.
+import ImportDomainPackage from "./ImportDomainPackage.vue";
+import DomainCapabilityDetail from "./DomainCapabilityDetail.vue";
+
+const props = defineProps({
+  id: { type: Number, default: null },
+  go: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+  onBack: { type: Function, default: () => {} },
+  onCreated: { type: Function, default: () => {} },
+  viewCompletePrompt: { type: Function, default: () => {} },
+  viewCompleteAssetsPrompt: { type: Function, default: () => {} },
+  viewCompleteCubesPrompt: { type: Function, default: () => {} },
+  pageStyles: { type: Array, default: () => [] },
+  onEditAssets: { type: Function, default: () => {} },
+  onOpenDeviceTemplate: { type: Function, default: () => {} },
+  onCreateSite: { type: Function, default: () => {} },
+});
+
+const doms = ref([]);
+const loadDoms = async () => {
+  try {
+    doms.value = (await fetchDb("/domains")) || [];
+  } catch (e) {
+    /* ignore */
+  }
+};
+onMounted(loadDoms);
+</script>
+
+<template>
+  <ImportDomainPackage v-if="id == null" :doms="doms" :notify="notify" :on-imported="onCreated" />
+  <DomainCapabilityDetail
+    v-else
+    :id="id"
+    :doms="doms"
+    :notify="notify"
+    :on-back="onBack"
+    :on-edit-assets="onEditAssets"
+    :on-open-device-template="onOpenDeviceTemplate"
+    :on-create-site="onCreateSite"
+  />
+</template>
diff --git a/src-vue/pages/domain/DomainFunctionsStep.vue b/src-vue/pages/domain/DomainFunctionsStep.vue
new file mode 100644 (file)
index 0000000..8ca92bc
--- /dev/null
@@ -0,0 +1,186 @@
+<script setup>
+import { ref, computed, watch } from "vue";
+import Panel from "../../components/Panel.vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiInput from "../../ui/UiInput.vue";
+import UiTable from "../../ui/UiTable.vue";
+import UiActions from "../../ui/UiActions.vue";
+import UiTabbedRegion from "../../ui/UiTabbedRegion.vue";
+import Button from "../../components/PrimaryButton.vue";
+import { buildMuLeaves } from "../../lib/api.js";
+
+const props = defineProps({
+  muTree: { type: Array, default: () => [] },
+  grouped: { type: Array, default: () => [] },
+  effSelected: { type: Array, default: () => [] },
+  onToggleMu: { type: Function, default: () => {} },
+  pages: { type: Array, default: () => [] },
+  onAddPage: { type: Function, default: () => {} },
+  onUploadPageJson: { type: Function, default: () => {} },
+  onSavePageMeta: { type: Function, default: () => {} },
+  onDeletePage: { type: Function, default: () => {} },
+  onGeneratePage: { type: Function, default: () => {} },
+  onDownloadPage: { type: Function, default: () => {} },
+  onPreviewPage: { type: Function, default: () => {} },
+  genIds: { type: Object, default: () => new Set() },
+  genLog: { type: String, default: "" },
+  cubesAi: { type: Object, default: () => ({ completing: { value: false }, log: { value: "" }, ref: { value: null } }) },
+  onRunCubesComplete: { type: Function, default: () => {} },
+  onViewCompleteCubesPrompt: { type: Function, default: () => {} },
+  updatePageField: { type: Function, default: () => {} },
+  tName: { type: String, default: "" },
+  saving: { type: Boolean, default: false },
+  notify: { type: Function, default: () => {} },
+  pageStyles: { type: Array, default: () => [] },
+  styleId: { type: Number, default: null },
+  onSelectStyle: { type: Function, default: () => {} },
+  onGenerateAll: { type: Function, default: () => {} },
+});
+
+const tab = ref("cube");
+const genLogRef = ref(null);
+
+watch(
+  () => props.genLog,
+  () => {
+    if (genLogRef.value) genLogRef.value.scrollTop = genLogRef.value.scrollHeight;
+  }
+);
+
+const cubesCompleting = computed(() => (props.cubesAi && props.cubesAi.completing ? props.cubesAi.completing.value : false));
+const cubesLog = computed(() => (props.cubesAi && props.cubesAi.log ? props.cubesAi.log.value : ""));
+
+function sourceBadge(src) {
+  if (src === "upload") return "↑ 上传";
+  if (src === "ai") return "✦ AI生成";
+  return "手动";
+}
+</script>
+
+<template>
+  <Panel>
+    <UiTabbedRegion>
+      <template #navigation>
+        <div class="tabs">
+          <UiButton :class="tab === 'cube' ? 'active' : ''" @click="() => (tab = 'cube')">领域扩展页面 <i>{{ pages.length }}</i></UiButton>
+          <UiButton :class="tab === 'mu' ? 'active' : ''" @click="() => (tab = 'mu')">MU 标准页面 <i>{{ effSelected.length }}</i></UiButton>
+          <UiButton :class="tab === 'menu' ? 'active' : ''" @click="() => (tab = 'menu')">菜单结构</UiButton>
+        </div>
+      </template>
+
+      <template v-if="tab === 'mu'">
+        <div class="mu-tree">
+          <div v-for="g in grouped" :key="g.parentMenu" class="mu-group">
+            <b>{{ g.parentMenu }}</b>
+            <div class="mu-items">
+              <label v-for="item in g.items" :key="item.key" :class="item.disabled ? 'disabled' : ''">
+                <UiInput type="checkbox" :disabled="item.disabled" :checked="effSelected.includes(item.key)" @change="() => onToggleMu(item.key)" />
+                <span>{{ item.label }}</span>
+              </label>
+            </div>
+          </div>
+        </div>
+      </template>
+
+      <template v-if="tab === 'cube'">
+        <div class="two-column">
+          <div>
+            <div class="style-picker">
+              <h4>领域方案页面样式 <small>方案级 · 本方案全部 Cube 页面共用</small></h4>
+              <p class="hint">选择一个样式库方案作为本方案所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
+              <div class="style-pick-list">
+                <UiButton :class="'style-pick' + (!styleId ? ' active' : '')" @click="() => onSelectStyle(null)">
+                  <div class="style-pick-thumb none">无</div><span>不使用样式</span>
+                </UiButton>
+                <UiButton v-for="s in pageStyles" :key="s.id" :class="'style-pick' + (styleId === s.id ? ' active' : '')" @click="() => onSelectStyle(s.id)">
+                  <div class="style-pick-thumb"><img :src="`/api/db/page-styles/${s.id}/image?w=360`" :alt="s.name" loading="lazy" /></div>
+                  <span>{{ s.name }}</span>
+                </UiButton>
+              </div>
+            </div>
+            <div class="cube-editor">
+              <UiTable class="data-table editable cube-editor-table">
+                <thead><tr><th>页面名称 <small>最多6字</small></th><th>页面描述</th><th>来源</th><th>Json文件</th><th>操作</th></tr></thead>
+                <tbody>
+                  <tr v-for="(p, i) in pages" :key="p.id">
+                    <td><UiInput :value="p.name" maxlength="6" placeholder="例如:运行总览" @change="(e) => updatePageField(i, { name: e.target.value })" /></td>
+                    <td><UiInput :value="p.description" placeholder="描述该页面要展示的内容与指标" @change="(e) => updatePageField(i, { description: e.target.value })" /></td>
+                    <td>
+                      <span v-if="p.source === 'upload'" class="badge src-upload">↑ 上传</span>
+                      <span v-else-if="p.source === 'ai'" class="badge src-ai">✦ AI生成</span>
+                      <span v-else class="badge src-manual">手动</span>
+                    </td>
+                    <td>
+                      <UiButton v-if="p.gen_filename" class="link" @click="() => onDownloadPage(p)">下载 {{ p.gen_filename }}</UiButton>
+                      <span v-else class="muted">未生成</span>
+                    </td>
+                    <td class="cube-actions">
+                      <UiActions class="station-inline-actions">
+                        <UiButton @click="() => onPreviewPage(p)">预览</UiButton>
+                        <UiButton class="btn-ai" :disabled="genIds.has(p.id)" @click="() => onGeneratePage(p)">{{ genIds.has(p.id) ? "AI生成中" : "AI生成" }}</UiButton>
+                        <UiButton @click="() => onSavePageMeta(p)">保存</UiButton>
+                        <UiButton class="trash" @click="() => onDeletePage(p)">删除</UiButton>
+                      </UiActions>
+                    </td>
+                  </tr>
+                </tbody>
+              </UiTable>
+            </div>
+            <div style="display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap">
+              <Button @click="onAddPage">+ 添加 Cube 页面</Button>
+              <label class="button secondary" style="cursor: pointer">
+                ↑ 上传页面 Json<input type="file" accept=".json,application/json" hidden @change="(e) => { const f = e.target.files && e.target.files[0]; if (f) onUploadPageJson(f); e.target.value = ''; }" />
+              </label>
+              <UiButton class="btn-ai" style="height: 30px" @click="onGenerateAll" title="对所有未生成的页面排队生成">⚡ AI 统一生成全部({{ pages.filter((p) => p.status !== 'generated').length }})</UiButton>
+            </div>
+            <div v-if="genIds.size > 0 || genLog" class="modal-console">
+              <div class="modal-console-head">AI 生成页面</div>
+              <pre ref="genLogRef" class="modal-console-body">{{ genLog || "(等待生成…)" }}</pre>
+            </div>
+          </div>
+          <div class="stack">
+            <Panel title="填写建议">
+              <ul class="tips">
+                <li>从方案业务与设备资产出发,规划监控维度(综合总览、能耗能效、设备健康、报警事件、环境监测等)。</li>
+                <li>页面名称简洁,不超过 6 个汉字;描述写清页面目标与关键指标。</li>
+                <li>每个 Cube 页面可「生成」(AI 创建 JSON)或「上传页面 Json」(使用已有 JSON)。</li>
+                <li>已生成页面可点击「预览」查看效果,并在预览中提交修改意见重新生成。</li>
+              </ul>
+            </Panel>
+            <Panel title="AI 智能补全">
+              <p class="hint">基于方案名称 / 领域 / 描述 / 补充描述及资产,由 AI 推断并补充 Cube 扩展页面(追加在已有页面下方)。</p>
+              <div style="display: flex; gap: 8px; align-items: center">
+                <UiButton class="button" :disabled="cubesCompleting" @click="onRunCubesComplete">{{ cubesCompleting ? "补全中…" : "开始补全" }}</UiButton>
+                <UiButton class="text-button" @click="onViewCompleteCubesPrompt">查看提示词</UiButton>
+              </div>
+              <div v-if="cubesCompleting || cubesLog" class="modal-console">
+                <div class="modal-console-head">AI 智能补全</div>
+                <pre class="modal-console-body" :ref="(el) => { if (props.cubesAi && props.cubesAi.ref) props.cubesAi.ref.value = el; }">{{ cubesLog || "(等待 AI 分析…)" }}</pre>
+              </div>
+            </Panel>
+            <Panel v-if="saving"><div class="empty-state">正在保存…</div></Panel>
+          </div>
+        </div>
+      </template>
+
+      <template v-if="tab === 'menu'">
+        <div class="menu-preview">
+          <p class="hint">默认菜单结构(展示用,无需编辑):MU 标准页面在上,所有 Cube 扩展页面归入「综合监控」一级菜单下。</p>
+          <div class="menu-tree">
+            <div class="menu-root">⌄ {{ tName || "未命名方案" }}</div>
+            <div class="menu-section">
+              <div class="menu-section-title">MU 标准页面</div>
+              <div v-if="effSelected.length === 0" class="menu-leaf muted">(未选择 MU 页面)</div>
+              <div v-else v-for="l in buildMuLeaves(muTree).filter((x) => effSelected.includes(x.key))" :key="l.key" class="menu-leaf">· {{ l.parentMenu }}/{{ l.label }}</div>
+            </div>
+            <div class="menu-section">
+              <div class="menu-section-title">综合监控</div>
+              <div v-if="pages.filter((p) => p.name && p.name.trim()).length === 0" class="menu-leaf muted">(暂无 Cube 页面)</div>
+              <div v-else v-for="p in pages.filter((p) => p.name && p.name.trim())" :key="p.id" class="menu-leaf cube">· {{ p.name }}</div>
+            </div>
+          </div>
+        </div>
+      </template>
+    </UiTabbedRegion>
+  </Panel>
+</template>
diff --git a/src-vue/pages/domain/DomainGenerateReview.vue b/src-vue/pages/domain/DomainGenerateReview.vue
new file mode 100644 (file)
index 0000000..a72b983
--- /dev/null
@@ -0,0 +1,157 @@
+<script setup>
+import { ref, computed } from "vue";
+import Panel from "../../components/Panel.vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiTabbedRegion from "../../ui/UiTabbedRegion.vue";
+import Status from "../../components/StatusTag.vue";
+import DomainPageThumb from "../../components/DomainPageThumb.vue";
+import { fetchDb, formatInstances } from "../../lib/api.js";
+
+const props = defineProps({
+  m: { type: Object, default: () => ({}) },
+  domainName: { type: String, default: "" },
+  assets: { type: Array, default: () => [] },
+  effSelected: { type: Array, default: () => [] },
+  grouped: { type: Array, default: () => [] },
+  pages: { type: Array, default: () => [] },
+  docs: { type: Array, default: () => [] },
+  onBack: { type: Function, default: () => {} },
+  onPreviewPage: { type: Function, default: null },
+});
+
+const tab = ref("dev");
+const devOpen = ref(null);
+const devDetail = ref(null);
+const devLoading = ref(false);
+
+async function openDev(id) {
+  if (devOpen.value === id) {
+    devOpen.value = null;
+    return;
+  }
+  devOpen.value = id;
+  devLoading.value = true;
+  devDetail.value = null;
+  try {
+    const d = await fetchDb(`/device-templates/${id}`);
+    devDetail.value = d;
+  } catch (e) {
+    /* ignore */
+  } finally {
+    devLoading.value = false;
+  }
+}
+
+const devList = computed(() =>
+  Array.from(new Map(props.assets.filter((a) => a.device_template_id).map((a) => [a.device_template_id, a])).values())
+);
+const assetGroups = computed(() => props.assets.filter((a) => a.device_template_id));
+const muItems = computed(() =>
+  props.grouped.flatMap((g) => g.items).filter((l) => props.effSelected.includes(l.key))
+);
+
+const tabs = [
+  { key: "dev", label: `设备模板 ${devList.value.length}` },
+  { key: "asset", label: `资产结构 ${assetGroups.value.length}` },
+  { key: "mu", label: `MU页面 ${props.effSelected.length}` },
+  { key: "cube", label: `扩展页面 ${props.pages.length}` },
+  { key: "knowledge", label: `领域知识 ${props.docs.length}` },
+  { key: "ops", label: "运维流程" },
+];
+</script>
+
+<template>
+  <div class="gen-overview">
+    <Panel title="方案概览">
+      <div class="overview-info">
+        <div><span>所属领域</span><b>{{ domainName || "—" }}</b></div>
+        <div><span>方案名称</span><b>{{ m.name || "—" }}</b></div>
+        <div class="overview-desc"><span>方案描述</span><p>{{ m.description || "—" }}</p></div>
+        <div class="overview-desc"><span>补充描述</span><p>{{ m.supplement || "—" }}</p></div>
+      </div>
+      <UiTabbedRegion>
+        <template #navigation>
+          <div class="tabs overview-tabs">
+            <UiButton v-for="t in tabs" :key="t.key" :class="tab === t.key ? 'active' : ''" @click="() => (tab = t.key)">{{ t.label }}</UiButton>
+          </div>
+        </template>
+
+        <div class="overview-body">
+          <div v-if="tab === 'dev'" class="dev-list">
+            <p v-if="devList.length === 0" class="muted">本方案未选用设备模板。</p>
+            <div v-for="a in devList" :key="a.device_template_id" class="dev-row">
+              <UiButton class="dev-row-head" @click="() => openDev(a.device_template_id)">
+                <b>{{ a.device_name }}</b><span class="dev-count">{{ a.count }} 台</span><span class="dev-caret">{{ devOpen === a.device_template_id ? "▾" : "▸" }}</span>
+              </UiButton>
+              <div v-if="devOpen === a.device_template_id" class="dev-detail">
+                <p v-if="devLoading" class="muted">加载中…</p>
+                <template v-else-if="devDetail">
+                  <p class="dev-desc">{{ devDetail.description || "(无说明)" }}</p>
+                  <div class="dev-cp">
+                    <b>采集点({{ (devDetail.collection_points || []).length }})</b>
+                    <span v-if="(devDetail.collection_points || []).length === 0" class="muted"> 无</span>
+                    <ul v-else>
+                      <li v-for="(cp, i) in (devDetail.collection_points || []).slice(0, 40)" :key="i">{{ cp.name }}{{ cp.unit ? `(${cp.unit})` : "" }}</li>
+                    </ul>
+                  </div>
+                </template>
+                <p v-else class="muted">无法加载详情。</p>
+              </div>
+            </div>
+          </div>
+
+          <div v-if="tab === 'asset'" class="asset-tree-preview">
+            <div class="tree-root">⌄ <b>{{ m.name || "未命名方案" }}</b></div>
+            <div v-for="(row, i) in assetGroups" :key="i" class="tree-branch">
+              <div class="tree-node">⌄ {{ row.device_name }}<span class="tree-count">{{ row.count }}</span></div>
+              <div v-if="row.count > 0" class="tree-leaves">{{ formatInstances(row.rule, row.count) }}</div>
+            </div>
+          </div>
+
+          <div v-if="tab === 'mu'" class="mu-tree">
+            <p v-if="grouped.length === 0" class="muted">(未加载 MU 树)</p>
+            <div v-else v-for="g in grouped" :key="g.parentMenu" class="mu-group">
+              <b>{{ g.parentMenu }}</b>
+              <div class="mu-items">
+                <label v-for="item in g.items" :key="item.key" class="disabled">
+                  <UiInput type="checkbox" disabled :checked="effSelected.includes(item.key)" @change="() => {}" /><span>{{ item.label }}</span>
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <div v-if="tab === 'cube'" class="domain-review-pages">
+            <div v-if="pages.length === 0" class="muted">暂无扩展页面,返回「功能与菜单 → 领域扩展页面」生成。</div>
+            <article
+              v-else
+              v-for="p in pages"
+              :key="p.id"
+              class="domain-review-page"
+              style="cursor: pointer"
+              @click="() => onPreviewPage && onPreviewPage(p)"
+            >
+              <DomainPageThumb :json-text="p.json_text" empty-label="未生成" />
+              <div>
+                <Status>{{ p.status === "generated" ? "AI已生成" : p.status === "uploaded" ? "已上传" : "未生成" }}{{ p.source ? ` · ${p.source === "ai" ? "AI" : p.source === "upload" ? "上传" : "手动"}` : "" }}</Status>
+                <h4>{{ p.name }}</h4>
+                <span class="review-zoom">⛶ 点击放大</span>
+              </div>
+            </article>
+          </div>
+
+          <div v-if="tab === 'knowledge'" class="doc-list">
+            <p v-if="docs.length === 0" class="muted">未上传领域知识,返回「领域知识」步骤上传。</p>
+            <div v-else v-for="(d, i) in docs" :key="i" class="doc-item">
+              <b>{{ d.name }}</b><span class="muted">{{ d.type === "link" ? "参考站点" : d.mime || "文档" }}{{ (d.size || d.file_size) ? ` · ${((Number(d.size || d.file_size) / 1024).toFixed(1))}KB` : "" }}</span>
+            </div>
+          </div>
+
+          <div v-if="tab === 'ops'" class="ops-placeholder">
+            <span class="status muted">待开发</span>
+            <p>运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。</p>
+          </div>
+        </div>
+      </UiTabbedRegion>
+    </Panel>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/DomainKnowledgeStep.vue b/src-vue/pages/domain/DomainKnowledgeStep.vue
new file mode 100644 (file)
index 0000000..e2fb1fa
--- /dev/null
@@ -0,0 +1,139 @@
+<script setup>
+import { ref } from "vue";
+import Panel from "../../components/Panel.vue";
+import Field from "../../components/Field.vue";
+import UiInput from "../../ui/UiInput.vue";
+import UiTable from "../../ui/UiTable.vue";
+import Button from "../../components/PrimaryButton.vue";
+import Status from "../../components/StatusTag.vue";
+import { fetchDb } from "../../lib/api.js";
+import { useConfirm } from "./useConfirm.js";
+
+const props = defineProps({
+  id: { type: Number, required: true },
+  docs: { type: Array, default: () => [] },
+  notify: { type: Function, default: () => {} },
+  reload: { type: Function, default: () => {} },
+  confirm: { type: Function, default: null },
+});
+
+const busy = ref(false);
+const urlName = ref("");
+const urlVal = ref("");
+
+function readBase64(file) {
+  return new Promise((resolve, reject) => {
+    const r = new FileReader();
+    r.onload = () => resolve({ filename: file.name, mime: file.type || "application/octet-stream", data: String(r.result).split(",")[1] });
+    r.onerror = () => reject(r.error);
+    r.readAsDataURL(file);
+  });
+}
+
+async function uploadFiles(files) {
+  if (!files || !files.length) return;
+  busy.value = true;
+  try {
+    for (const f of Array.from(files)) {
+      const b = await readBase64(f);
+      await fetchDb(`/domain-templates/${props.id}/docs`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ doc_type: "file", name: f.name, file: { filename: b.filename, mime: b.mime, data: b.data } }),
+      });
+    }
+    props.notify("已上传知识文档");
+    props.reload();
+  } catch (e) {
+    props.notify("上传失败:" + (e && e.message ? e.message : e));
+  } finally {
+    busy.value = false;
+  }
+}
+
+const { confirm: confirmFn, el: confirmEl } = useConfirm();
+const doConfirm = props.confirm || confirmFn;
+
+async function addUrl() {
+  const name = urlName.value.trim();
+  const url = urlVal.value.trim();
+  if (!name) {
+    props.notify("请填写站点名称");
+    return;
+  }
+  if (!/^https?:\/\//i.test(url)) {
+    props.notify("请输入有效的 http(s) 链接");
+    return;
+  }
+  busy.value = true;
+  try {
+    await fetchDb(`/domain-templates/${props.id}/docs`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ doc_type: "link", name, url }),
+    });
+    props.notify("已添加参考站点");
+    urlName.value = "";
+    urlVal.value = "";
+    props.reload();
+  } catch (e) {
+    props.notify("添加失败:" + (e && e.message ? e.message : e));
+  } finally {
+    busy.value = false;
+  }
+}
+
+async function removeDoc(docId) {
+  if (!(await doConfirm("确定删除该知识来源?此操作不可恢复"))) return;
+  try {
+    await fetchDb(`/knowledge-docs/${docId}`, { method: "DELETE" });
+    props.notify("已删除");
+    props.reload();
+  } catch (e) {
+    props.notify("删除失败:" + (e && e.message ? e.message : e));
+  }
+}
+</script>
+
+<template>
+  <component :is="confirmEl" />
+  <div class="two-column">
+    <Panel title="上传领域知识文档">
+      <template v-if="busy" #tools><Status>上传中…</Status></template>
+      <p class="hint">上传与本站领域相关的文档(标准规范、设备手册、工艺说明、建设方案等)。知识文本将作为资产配置、功能菜单 AI 补全的优先参考资料,并随方案保存。支持 Word / Excel / PDF / 图片及常见文本格式;Word、Excel、PDF 会提取正文注入,图片仅作参考登记。</p>
+      <label class="upload" style="height: 96px">
+        <b>+</b><span>{{ busy ? "上传中…" : "选择文件或拖拽到此处(可多选)" }}</span>
+        <input type="file" multiple hidden accept=".txt,.md,.markdown,.json,.csv,.log,.html,.xml,.yaml,.yml,.pdf,.doc,.docx,.xls,.xlsx,image/*" @change="(e) => uploadFiles(e.target.files)" />
+      </label>
+    </Panel>
+    <Panel title="配置参考 URL 站点">
+      <p class="hint">填写一个领域相关的参考网站地址。AI 补全时会由服务端抓取该站点正文并注入提示词(抓取失败则仅保留链接作为参考)。</p>
+      <div style="display: flex; flex-direction: column; gap: 8px">
+        <UiInput placeholder="站点名称,如:XX 行业标准" :value="urlName" @change="(e) => (urlName = e.target.value)" />
+        <UiInput placeholder="https://..." :value="urlVal" @change="(e) => (urlVal = e.target.value)" />
+        <div><Button :disabled="busy" @click="addUrl">添加站点</Button></div>
+      </div>
+    </Panel>
+    <Panel :title="`已沉淀领域知识(${docs.length})`">
+      <template #tools><Button secondary @click="reload">刷新</Button></template>
+      <div v-if="docs.length === 0" class="empty-state">暂无领域知识,上传文档或配置站点后此处展示。</div>
+      <UiTable v-else class="data-table">
+        <thead><tr><th>名称</th><th>类型</th><th>大小</th><th>创建时间</th><th>操作</th></tr></thead>
+        <tbody>
+          <tr v-for="d in docs" :key="d.id">
+            <td>{{ d.name }}</td>
+            <td>
+              <span :style="{ padding: 'var(--ant-padding-xxs) var(--ant-padding-xs)', borderRadius: 'var(--ant-border-radius)', fontSize: 'var(--ant-font-size-sm)', background: 'var(--ant-color-primary-bg)', color: 'var(--ant-color-primary)' }">{{ d.doc_type === 'link' ? 'URL 站点' : '文件' }}</span>
+            </td>
+            <td>{{ d.size != null ? `${Math.round(Number(d.size) / 1024 * 10) / 10} KB` : '—' }}</td>
+            <td>{{ d.created_at || '' }}</td>
+            <td style="display: flex; gap: 8px">
+              <a v-if="d.doc_type === 'file'" class="text-button" :href="`/api/db/knowledge-docs/${d.id}/file`" target="_blank" rel="noreferrer">下载</a>
+              <UiButton class="text-button" @click="() => removeDoc(d.id)">删除</UiButton>
+            </td>
+          </tr>
+        </tbody>
+      </UiTable>
+    </Panel>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/DomainPagePreviewModal.vue b/src-vue/pages/domain/DomainPagePreviewModal.vue
new file mode 100644 (file)
index 0000000..7cf5511
--- /dev/null
@@ -0,0 +1,93 @@
+<script setup>
+import { ref, computed, watch } from "vue";
+import { Button as AntButton } from "ant-design-vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiInput from "../../ui/UiInput.vue";
+import UiOverlay from "../../ui/UiOverlay.vue";
+import DomainPageThumb from "../../components/DomainPageThumb.vue";
+import { streamInto } from "./domainCommon.js";
+
+const props = defineProps({
+  page: { type: Object, default: () => ({}) },
+  tmplId: { type: Number, default: 0 },
+  notify: { type: Function, default: () => {} },
+  onClose: { type: Function, default: () => {} },
+});
+
+const feedback = ref("");
+const refining = ref(false);
+const refineLog = ref("");
+const consoleRef = ref(null);
+
+watch(refineLog, () => {
+  const node = consoleRef.value;
+  if (node) node.scrollTop = node.scrollHeight;
+});
+
+const hasJson = computed(
+  () => !!props.page.json_text && props.page.json_text.trim() !== "{}" && props.page.json_text.trim() !== ""
+);
+
+const setRefineLog = (v) => {
+  refineLog.value = typeof v === "function" ? v(refineLog.value) : v;
+};
+
+const regenerate = async () => {
+  if (!feedback.value.trim()) {
+    notify("请填写修改意见");
+    return;
+  }
+  refining.value = true;
+  setRefineLog("");
+  const watchdog = setTimeout(() => {
+    refining.value = false;
+    notify("重新生成超时(>10 分钟),请重试");
+  }, 600000);
+  try {
+    const res = await fetch(`/api/db/domain-templates/${props.tmplId}/pages/${props.page.id}/refine`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ feedback: feedback.value }),
+    });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    await streamInto(res, setRefineLog, () => {});
+    notify(`已根据修改意见重新生成「${props.page.name}」`);
+    onClose(true);
+  } catch (e) {
+    notify("重新生成失败:" + (e && e.message ? e.message : e));
+  } finally {
+    clearTimeout(watchdog);
+    refining.value = false;
+  }
+};
+</script>
+
+<template>
+  <UiOverlay class="modal-backdrop" @mousedown="() => onClose(false)">
+    <div class="modal" style="width: min(1100px, 94vw); max-width: 94vw" @mousedown.stop>
+      <header>
+        <h2>{{ page.name || "页面预览" }}</h2>
+        <UiButton @click="() => onClose(false)">×</UiButton>
+      </header>
+      <div class="modal-body">
+        <div class="domain-preview-canvas">
+          <DomainPageThumb v-if="hasJson" :json-text="page.json_text" fit />
+          <div v-else class="thumb-empty">尚未生成,请点击「生成」后再预览</div>
+        </div>
+        <div class="domain-preview-feedback">
+          <h4>修改意见</h4>
+          <p class="hint">输入对当前页面的修改意见,AI 将据此重新生成该页面。</p>
+          <UiInput
+            textarea
+            :value="feedback"
+            :rows="4"
+            @change="(e) => (feedback = e.target.value)"
+            placeholder="例如:把能耗图表改为按周维度对比,并增加告警列表"
+          />
+          <pre v-if="refining" ref="consoleRef" class="modal-console-body">{{ refineLog || "(重新生成中…)" }}</pre>
+          <AntButton :disabled="refining" @click="regenerate">{{ refining ? "重新生成中…" : "根据意见重新生成" }}</AntButton>
+        </div>
+      </div>
+    </div>
+  </UiOverlay>
+</template>
diff --git a/src-vue/pages/domain/DomainSteps.vue b/src-vue/pages/domain/DomainSteps.vue
new file mode 100644 (file)
index 0000000..73ae319
--- /dev/null
@@ -0,0 +1,43 @@
+<script setup>
+import { computed } from "vue";
+import UiButton from "../../ui/UiButton.vue";
+import { DOMAIN_STEPS } from "./domainCommon.js";
+
+const props = defineProps({
+  step: { type: String, default: "basic" },
+  go: { type: Function, default: () => {} },
+  completed: { type: Boolean, default: false },
+  configured: { type: Object, default: () => ({}) },
+});
+
+const currentIndex = computed(() => DOMAIN_STEPS.findIndex((x) => x.key === props.step));
+
+function isDone(item, index) {
+  return props.completed ? !!props.configured[item.key] : index < currentIndex.value;
+}
+function clickable(index) {
+  return props.completed || index <= currentIndex.value;
+}
+function stepClass(item, index) {
+  const cls = index === currentIndex.value ? "active" : isDone(item, index) ? "done" : "";
+  return cls + (clickable(index) ? "" : " locked");
+}
+function onStepClick(item, index) {
+  if (clickable(index)) props.go(item.key);
+}
+</script>
+
+<template>
+  <div class="steps domain-steps">
+    <UiButton
+      v-for="(item, index) in DOMAIN_STEPS"
+      :key="item.key"
+      :class="stepClass(item, index)"
+      @click="() => onStepClick(item, index)"
+    >
+      <i>{{ isDone(item, index) ? "✓" : index + 1 }}</i>
+      <span><small>STEP {{ index + 1 }}</small>{{ item.label }}</span>
+      <b v-if="index < 6">›</b>
+    </UiButton>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/DomainStyleLibrary.vue b/src-vue/pages/domain/DomainStyleLibrary.vue
new file mode 100644 (file)
index 0000000..9f99849
--- /dev/null
@@ -0,0 +1,67 @@
+<script setup>
+import { ref } from "vue";
+import Panel from "../../components/Panel.vue";
+import UiButton from "../../ui/UiButton.vue";
+import Button from "../../components/PrimaryButton.vue";
+import StylePreview from "./StylePreview.vue";
+import StyleEditorModal from "./StyleEditorModal.vue";
+import { fetchDb } from "../../lib/api.js";
+import { useConfirm } from "./useConfirm.js";
+
+const props = defineProps({
+  pageStyles: { type: Array, default: () => [] },
+  loadPageStyles: { type: Function, default: async () => {} },
+  notify: { type: Function, default: () => {} },
+});
+
+const editor = ref({ open: false, id: undefined });
+const preview = ref(null);
+const { confirm, el: confirmEl } = useConfirm();
+
+async function afterChange(msg) {
+  props.notify(msg);
+  editor.value = { open: false, id: undefined };
+  await props.loadPageStyles();
+}
+function openEdit(s) {
+  editor.value = { open: true, id: s.id };
+}
+async function remove(s) {
+  if (!(await confirm(`确定删除样式「${s.name}」?此操作不可恢复`))) return;
+  try {
+    await fetchDb(`/page-styles/${s.id}`, { method: "DELETE" });
+    await afterChange(`已删除「${s.name}」`);
+  } catch (e) {
+    props.notify("删除失败: " + (e && e.message ? e.message : e));
+  }
+}
+</script>
+
+<template>
+  <component :is="confirmEl" />
+  <StylePreview v-if="preview" :style="preview" :on-close="() => (preview = null)" />
+  <Panel title="全部页面样式">
+    <template #tools><Button @click="() => (editor = { open: true })">+ 新增样式</Button></template>
+    <p v-if="pageStyles.length === 0" class="hint">暂无页面样式,点击右上角「+ 新增样式」上传 JSON 与预览图即可创建。</p>
+    <div v-else class="style-grid">
+      <article v-for="s in pageStyles" :key="s.id" class="style-card">
+        <div class="style-thumb" title="点击查看大图" @click="() => (preview = s)">
+          <img :src="`/api/db/page-styles/${s.id}/image?w=560`" :alt="s.name" loading="lazy" />
+          <span class="zoom">⛶</span>
+        </div>
+        <div class="style-info"><h3>{{ s.name }}</h3><small>{{ s.created_at || "" }}</small></div>
+        <footer>
+          <UiButton class="text-button" @click="() => openEdit(s)">✎ 编辑</UiButton>
+          <UiButton class="text-button" @click="() => remove(s)">🗑 删除</UiButton>
+        </footer>
+      </article>
+    </div>
+  </Panel>
+  <StyleEditorModal
+    v-if="editor.open"
+    :id="editor.id"
+    :notify="notify"
+    :after-change="afterChange"
+    :on-close="() => (editor = { open: false, id: undefined })"
+  />
+</template>
diff --git a/src-vue/pages/domain/DomainWizard.vue b/src-vue/pages/domain/DomainWizard.vue
new file mode 100644 (file)
index 0000000..063d9d7
--- /dev/null
@@ -0,0 +1,709 @@
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { fetchDb, formatInstances, buildMuLeaves, downloadBlob } from "../../lib/api.js";
+import { DOMAIN_STEPS, useStreamComplete, streamInto } from "./domainCommon.js";
+import UiButton from "../../ui/UiButton.vue";
+import UiInput from "../../ui/UiInput.vue";
+import UiSelect from "../../ui/UiSelect.vue";
+import UiTable from "../../ui/UiTable.vue";
+import UiOverlay from "../../ui/UiOverlay.vue";
+import Panel from "../../components/Panel.vue";
+import Field from "../../components/Field.vue";
+import Button from "../../components/PrimaryButton.vue";
+import Status from "../../components/StatusTag.vue";
+import DomainSteps from "./DomainSteps.vue";
+import DomainKnowledgeStep from "./DomainKnowledgeStep.vue";
+import DomainFunctionsStep from "./DomainFunctionsStep.vue";
+import DomainGenerateReview from "./DomainGenerateReview.vue";
+import PackageModal from "./PackageModal.vue";
+import DeviceTemplateCreate from "./DeviceTemplateCreate.vue";
+import DomainPagePreviewModal from "./DomainPagePreviewModal.vue";
+import { useConfirm } from "./useConfirm.js";
+
+const props = defineProps({
+  id: { type: Number, required: true },
+  doms: { type: Array, default: () => [] },
+  go: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+  onBack: { type: Function, default: () => {} },
+  viewCompletePrompt: { type: Function, default: () => {} },
+  viewCompleteAssetsPrompt: { type: Function, default: () => {} },
+  viewCompleteCubesPrompt: { type: Function, default: () => {} },
+  pageStyles: { type: Array, default: () => [] },
+});
+
+const t = ref(null);
+const m = ref(null);
+const assets = ref([]);
+const mu = ref([]);
+const pages = ref([]);
+const allDevices = ref([]);
+const muTreeLocal = ref([]);
+const step = ref("basic");
+const loading = ref(true);
+const saving = ref(false);
+const devCreateOpen = ref(false);
+const pendingDevRow = ref(null);
+const previewPage = ref(null);
+const pkgOpen = ref(false);
+
+const basicAi = useStreamComplete();
+const assetsAi = useStreamComplete();
+const cubesAi = useStreamComplete();
+const { completing: basicCompleting, log: basicLog, ref: basicRef, setCompleting: setBasicCompleting, setLog: setBasicLog } = basicAi;
+const { completing: assetsCompleting, log: assetsLog, ref: assetsRef, setCompleting: setAssetsCompleting, setLog: setAssetsLog } = assetsAi;
+
+const genIds = ref(new Set());
+const genLog = ref("");
+const { confirm, el: confirmEl } = useConfirm();
+
+function setGenLog(v) {
+  genLog.value = typeof v === "function" ? v(genLog.value) : v;
+}
+
+const domainName = computed(() => {
+  const d = props.doms.find((d) => d.id === m.value?.domain_id);
+  return d ? d.name : "";
+});
+const allMuKeys = computed(() => buildMuLeaves(muTreeLocal.value).map((l) => l.key));
+const effSelected = computed(() => (mu.value.length ? mu.value : allMuKeys.value));
+const grouped = computed(() => {
+  const leaves = buildMuLeaves(muTreeLocal.value);
+  const map = new Map();
+  for (const l of leaves) {
+    if (!map.has(l.parentMenu)) map.set(l.parentMenu, { parentMenu: l.parentMenu, items: [] });
+    map.get(l.parentMenu).items.push(l);
+  }
+  return Array.from(map.values());
+});
+
+async function loadAllDevices() {
+  try {
+    allDevices.value = (await fetchDb("/device-templates")) || [];
+  } catch (e) {}
+}
+async function load(silent = false) {
+  if (!silent) loading.value = true;
+  try {
+    const [d, mt] = await Promise.all([fetchDb(`/domain-templates/${props.id}`), fetchDb(`/mu-tree`)]);
+    muTreeLocal.value = mt || [];
+    t.value = d;
+    m.value = {
+      domain_id: d.domain_id,
+      name: d.name,
+      description: d.description || "",
+      status: d.status,
+      version: d.version,
+      creator: d.creator || "",
+      supplement: d.supplement || "",
+      style_id: d.style_id != null ? Number(d.style_id) : null,
+      docs: d.docs || [],
+      flows: d.flows || [],
+    };
+    assets.value = (d.assets || []).map((a) => ({
+      id: a.id,
+      device_template_id: a.device_template_id,
+      device_name: a.device_name || "",
+      count: Number(a.count) || 0,
+      rule: a.rule || "",
+      sort_order: a.sort_order || 0,
+    }));
+    pages.value = (d.pages || []).map((p) => ({
+      id: p.id,
+      name: p.name,
+      description: p.description || "",
+      json_text: p.json_text || "",
+      status: p.status || "pending",
+      gen_filename: p.gen_filename || null,
+      prompt: p.prompt || null,
+      source: p.source || null,
+      sort_order: p.sort_order || 0,
+    }));
+    mu.value = d.mu && d.mu.length ? d.mu : [];
+  } catch (e) {
+    props.notify("加载失败:" + (e && e.message ? e.message : e));
+  } finally {
+    if (!silent) loading.value = false;
+  }
+}
+onMounted(() => {
+  loadAllDevices();
+  load();
+});
+
+function patchM(patch) {
+  m.value = { ...(m.value || {}), ...patch };
+}
+
+async function patch(patchBody, msg = "已保存") {
+  saving.value = true;
+  try {
+    await fetchDb(`/domain-templates/${props.id}`, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(patchBody),
+    });
+    props.notify(msg);
+  } catch (e) {
+    props.notify("保存失败:" + (e && e.message ? e.message : e));
+  } finally {
+    saving.value = false;
+  }
+}
+const saveMeta = () => patch(m.value);
+
+async function saveAssets(next) {
+  const a = next ?? assets.value;
+  assets.value = a;
+  await patch(
+    { assets: a.map((x) => ({ device_template_id: x.device_template_id, count: x.count, rule: x.rule, sort_order: x.sort_order })) },
+    "资产已保存"
+  );
+}
+async function saveMu(next) {
+  const sel = next ?? mu.value;
+  mu.value = sel;
+  await patch({ mu: sel }, "MU 选择已保存");
+}
+const toggleMu = (key) =>
+  saveMu(effSelected.value.includes(key) ? effSelected.value.filter((k) => k !== key) : [...effSelected.value, key]);
+
+async function selectStyle(sid) {
+  try {
+    await fetchDb(`/domain-templates/${props.id}`, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ style_id: sid }),
+    });
+    patchM({ style_id: sid });
+    props.notify(sid ? "已应用页面样式" : "已清除页面样式");
+  } catch (e) {
+    props.notify("应用样式失败:" + (e && e.message ? e.message : e));
+  }
+}
+
+async function runBasicComplete() {
+  if (basicCompleting.value) return;
+  setBasicCompleting(true);
+  setBasicLog("");
+  let newSup = m.value.supplement || "";
+  const wd = setTimeout(() => {
+    setBasicCompleting(false);
+    props.notify("AI 智能补全超时(>6 分钟),请重试");
+  }, 360000);
+  try {
+    const res = await fetch(`/api/db/domain-templates/${props.id}/complete`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ name: m.value.name, domain: domainName.value, description: m.value.description }),
+    });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    await streamInto(res, setBasicLog, (ev) => {
+      if (ev.result !== undefined) {
+        newSup = ev.result;
+        patchM({ supplement: newSup });
+      }
+    });
+    await patch({ supplement: newSup }, "AI 已写入补充描述");
+  } catch (e) {
+    props.notify("AI 智能补全失败:" + (e && e.message ? e.message : e));
+  } finally {
+    clearTimeout(wd);
+    setBasicCompleting(false);
+  }
+}
+
+async function runAssetsComplete() {
+  if (assetsCompleting.value) return;
+  setAssetsCompleting(true);
+  setAssetsLog("");
+  const wd = setTimeout(() => {
+    setAssetsCompleting(false);
+    props.notify("AI 资产补全超时(>10 分钟),请重试");
+  }, 600000);
+  try {
+    const res = await fetch(`/api/db/domain-templates/${props.id}/complete-assets`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        name: m.value.name,
+        domain: domainName.value,
+        description: m.value.description,
+        supplement: m.value.supplement,
+        assets: assets.value.map((a) => ({ type: a.device_name, count: a.count, rule: a.rule })),
+      }),
+    });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    let created = [];
+    await streamInto(res, setAssetsLog, (ev) => {
+      if (Array.isArray(ev.result)) {
+        assets.value = [
+          ...assets.value,
+          ...ev.result.map((it, i) => ({
+            device_template_id: it.device_template_id,
+            device_name: allDevices.value.find((d) => d.id === it.device_template_id)?.name || "",
+            count: Number(it.count) || 0,
+            rule: it.rule || "",
+            sort_order: assets.value.length + i,
+          })),
+        ];
+      }
+      if (Array.isArray(ev.created)) created = ev.created;
+    });
+    await loadAllDevices();
+    await load(true);
+    props.notify("AI 资产补全已完成" + (created.length ? `,并自动创建设备模板:${created.join("、")}` : ""));
+  } catch (e) {
+    props.notify("AI 资产补全失败:" + (e && e.message ? e.message : e));
+  } finally {
+    clearTimeout(wd);
+    setAssetsCompleting(false);
+  }
+}
+
+async function runCubesComplete() {
+  if (cubesAi.completing.value) return;
+  cubesAi.setCompleting(true);
+  cubesAi.setLog("");
+  const wd = setTimeout(() => {
+    cubesAi.setCompleting(false);
+    props.notify("AI Cube 补全超时(>6 分钟),请重试");
+  }, 360000);
+  let failed = "";
+  let items = [];
+  try {
+    const res = await fetch(`/api/db/domain-templates/${props.id}/complete-cubes`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        name: m.value.name,
+        domain: domainName.value,
+        description: m.value.description,
+        supplement: m.value.supplement,
+        assets: assets.value.map((a) => ({ type: a.device_name, count: a.count, rule: a.rule })),
+      }),
+    });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    await streamInto(res, cubesAi.setLog, (ev) => {
+      if (Array.isArray(ev.result)) items = ev.result;
+      if (ev && ev.status === "failed") failed = ev.message || "AI 分析失败";
+    });
+    if (failed) {
+      props.notify("AI Cube 补全失败:" + failed);
+      return;
+    }
+    let added = 0;
+    for (const it of items) {
+      try {
+        await fetchDb(`/domain-templates/${props.id}/pages`, {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ name: it.name, description: it.content || "", json_text: "{}" }),
+        });
+        added++;
+      } catch (e) {
+        props.notify(`新增页面「${it.name || ""}」失败:${e.message}`);
+      }
+    }
+    await load(true);
+    props.notify(items.length ? `AI Cube 页面补全已完成,已新增 ${added} 个页面` : "AI 未返回可用页面");
+  } catch (e) {
+    props.notify("AI Cube 补全失败:" + (e && e.message ? e.message : e));
+  } finally {
+    clearTimeout(wd);
+    cubesAi.setCompleting(false);
+  }
+}
+
+function baseAssets() {
+  return assets.value.length
+    ? assets.value
+    : Array.from({ length: 4 }, (_, k) => ({ device_template_id: 0, device_name: "", count: 0, rule: "", sort_order: k }));
+}
+const assetRows = computed(() => baseAssets());
+function updateAsset(i, patchRow) {
+  const next = baseAssets().map((a, k) => (k === i ? { ...a, ...patchRow } : a));
+  saveAssets(next);
+}
+function addAssetRow() {
+  const next = [...baseAssets(), { device_template_id: 0, device_name: "", count: 0, rule: "", sort_order: baseAssets().length }];
+  saveAssets(next);
+}
+function removeAssetRow(i) {
+  const next = baseAssets().filter((_, k) => k !== i);
+  saveAssets(next);
+}
+function onAssetDeviceChange(i, val) {
+  if (val === "__new__") {
+    devCreateOpen.value = true;
+    pendingDevRow.value = i;
+    return;
+  }
+  const did = Number(val);
+  const dev = allDevices.value.find((d) => d.id === did);
+  updateAsset(i, { device_template_id: did, device_name: dev?.name || "" });
+}
+async function onDevCreated(nid) {
+  await loadAllDevices();
+  devCreateOpen.value = false;
+  if (pendingDevRow.value != null) {
+    const dev = allDevices.value.concat([{ id: nid }]).find((d) => d.id === nid);
+    updateAsset(pendingDevRow.value, { device_template_id: nid, device_name: dev?.name || "" });
+    pendingDevRow.value = null;
+  }
+}
+
+async function addPage() {
+  await fetchDb(`/domain-templates/${props.id}/pages`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ name: "新页面", description: "", json_text: "{}", source: "manual" }),
+  });
+  await load(true);
+  props.notify("已新增页面");
+}
+async function uploadPageJson(file, replaceId) {
+  const text = await file.text();
+  try {
+    JSON.parse(text);
+  } catch (e) {
+    props.notify("不是合法 JSON 文件");
+    return;
+  }
+  const name = file.name.replace(/\.json$/i, "");
+  try {
+    if (replaceId) {
+      await fetchDb(`/domain-pages/${replaceId}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ json_text: text }),
+      });
+      props.notify("已更新页面 JSON");
+    } else {
+      await fetchDb(`/domain-templates/${props.id}/pages`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name, description: "", json_text: text, source: "upload", gen_filename: file.name, status: "uploaded" }),
+      });
+      props.notify("已上传页面 JSON");
+    }
+    await load(true);
+  } catch (e) {
+    props.notify("上传失败:" + (e && e.message ? e.message : e));
+  }
+}
+async function savePageMeta(p) {
+  await fetchDb(`/domain-pages/${p.id}`, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ name: p.name, description: p.description, json_text: p.json_text }),
+  });
+  props.notify("页面已保存");
+}
+async function deletePage(p) {
+  if (!(await confirm(`删除页面「${p.name}」?`))) return;
+  await fetchDb(`/domain-pages/${p.id}`, { method: "DELETE" });
+  await load(true);
+}
+async function generatePage(p) {
+  if (genIds.value.has(p.id)) return;
+  const n = new Set(genIds.value);
+  n.add(p.id);
+  genIds.value = n;
+  setGenLog("");
+  props.notify(`开始生成「${p.name}」…`);
+  let failed = "";
+  try {
+    const res = await fetch(`/api/db/domain-templates/${props.id}/pages/${p.id}/generate`, { method: "POST" });
+    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
+    await streamInto(res, setGenLog, (ev) => {
+      if (ev && ev.status === "failed") failed = ev.message || "生成失败";
+    });
+    if (failed) props.notify(`「${p.name}」生成失败:${failed}`);
+    else props.notify(`「${p.name}」生成完成`);
+  } catch (e) {
+    props.notify("生成失败:" + (e && e.message ? e.message : e));
+  } finally {
+    const n2 = new Set(genIds.value);
+    n2.delete(p.id);
+    genIds.value = n2;
+    await load(true);
+  }
+}
+function generateAllPages() {
+  const pending = pages.value.filter((p) => p.status !== "generated" && !genIds.value.has(p.id));
+  if (!pending.length) {
+    props.notify("没有待生成的页面");
+    return;
+  }
+  props.notify(`已开始排队生成 ${pending.length} 个页面…`);
+  for (const p of pending) generatePage(p);
+}
+function downloadPage(p) {
+  const blob = new Blob([p.json_text || "{}"], { type: "application/json" });
+  downloadBlob(blob, p.gen_filename || `${p.name || "page"}.json`);
+}
+
+const curIdx = computed(() => DOMAIN_STEPS.findIndex((s) => s.key === step.value));
+function goStep(s) {
+  step.value = s;
+}
+const assetRowsForTree = computed(() => assets.value.filter((r) => r.device_template_id));
+
+const pageGenerated = computed(() => pages.value.some((p) => {
+  const tx = (p.json_text || "").trim();
+  return tx !== "" && tx !== "{}" && tx !== "null";
+}));
+const completed = computed(() => pageGenerated.value);
+const configured = computed(() => ({
+  basic: !!(m.value && m.value.name && m.value.description),
+  knowledge: ((m.value && m.value.docs) || []).length > 0,
+  assets: assets.value.some((a) => a.device_template_id),
+  functions: pages.value.length > 0 || effSelected.value.length > 0,
+  opsflow: ((m.value && m.value.flows) || []).length > 0,
+  generate: pageGenerated.value,
+  expert: m.value && m.value.status === "available",
+}));
+
+const kpiData = computed(() => [
+  [assets.value.filter((a) => a.device_template_id).length, "设备类型", "类"],
+  [assets.value.reduce((n, a) => n + Number(a.count || 0), 0), "设备实例", "个"],
+  [Math.max(2, assets.value.filter((a) => a.device_template_id).length + 1), "资产层级", "层"],
+]);
+
+function updatePageField(i, patchRow) {
+  pages.value = pages.value.map((p, idx) => (idx === i ? { ...p, ...patchRow } : p));
+}
+</script>
+
+<template>
+  <component :is="confirmEl" />
+  <p v-if="loading || !t || !m">加载中…</p>
+  <template v-else>
+    <UiOverlay v-if="devCreateOpen" class="modal-backdrop" @mousedown="() => { devCreateOpen = false; pendingDevRow = null; }">
+      <div class="modal" style="width: 640px" @mousedown.stop>
+        <header>
+          <h2>新增设备模版</h2>
+          <UiButton @click="() => { devCreateOpen = false; pendingDevRow = null; }">×</UiButton>
+        </header>
+        <div class="modal-body">
+          <DeviceTemplateCreate :notify="notify" :on-done="onDevCreated" :on-cancel="() => { devCreateOpen = false; pendingDevRow = null; }" />
+        </div>
+      </div>
+    </UiOverlay>
+
+    <DomainPagePreviewModal
+      v-if="previewPage"
+      :page="previewPage"
+      :tmpl-id="id"
+      :notify="notify"
+      :on-close="(reload) => { previewPage = null; if (reload) load(true); }"
+    />
+
+    <div class="domain-detail-head">
+      <UiButton class="text-button" @click="onBack">← 返回列表</UiButton>
+    </div>
+    <DomainSteps :step="step" :go="goStep" :completed="completed" :configured="configured" />
+
+    <div class="content">
+      <template v-if="step === 'basic'">
+        <div class="two-column basic-layout">
+          <Panel title="方案信息">
+            <div class="form-grid">
+              <Field label="所属领域" required>
+                <UiSelect :value="m.domain_id" @change="(e) => patchM({ domain_id: Number(e.target.value) })">
+                  <option v-for="d in doms" :key="d.id" :value="d.id">{{ d.name }}</option>
+                </UiSelect>
+              </Field>
+              <Field label="方案名称" required>
+                <UiInput :value="m.name" :maxlength="20" @change="(e) => patchM({ name: e.target.value })" />
+              </Field>
+              <Field label="版本">
+                <UiInput :value="m.version" @change="(e) => patchM({ version: e.target.value })" />
+              </Field>
+              <Field label="状态">
+                <UiSelect :value="m.status" @change="(e) => patchM({ status: e.target.value })">
+                  <option value="available">可用</option>
+                  <option value="unavailable">不可用</option>
+                </UiSelect>
+              </Field>
+              <Field label="创建人">
+                <UiInput :value="m.creator" @change="(e) => patchM({ creator: e.target.value })" />
+              </Field>
+              <Field label="方案描述" required hint="建议包含方案用途、重点设备、管理目标及关注指标">
+                <UiInput textarea :value="m.description" :rows="3" @change="(e) => patchM({ description: e.target.value })" />
+              </Field>
+              <Field label="补充描述" hint="可由右侧 AI 智能补全自动生成">
+                <UiInput textarea class="small" :value="m.supplement || ''" @change="(e) => patchM({ supplement: e.target.value })" />
+              </Field>
+              <div style="grid-column: 1 / -1"><Button @click="saveMeta">保存方案信息</Button></div>
+            </div>
+          </Panel>
+          <div class="stack">
+            <Panel title="填写建议">
+              <ul class="tips">
+                <li>这个方案服务于哪个业务场景?</li>
+                <li>涉及哪些核心设备与子系统?</li>
+                <li>管理人员最关注哪些运行指标?</li>
+                <li>希望方案落地后首先解决什么问题?</li>
+              </ul>
+            </Panel>
+            <Panel title="AI 智能补全">
+              <p class="hint">根据「方案名称 / 所属领域 / 方案描述」,由 AI 搜索同类型站点的物联网建设经验,自动生成补充描述到左侧「补充描述」。</p>
+              <div style="display: flex; gap: 8px; align-items: center">
+                <UiButton class="button" :disabled="basicCompleting" @click="runBasicComplete">{{ basicCompleting ? "补全中…" : "开始补全" }}</UiButton>
+                <UiButton class="text-button" @click="() => viewCompletePrompt(id, m.name, domainName, m.description)">查看提示词</UiButton>
+              </div>
+              <div v-if="basicCompleting || basicLog" class="modal-console">
+                <div class="modal-console-head">AI 智能补全</div>
+                <pre class="modal-console-body" :ref="(el) => (basicRef.value = el)">{{ basicLog || "(等待 AI 分析…)" }}</pre>
+              </div>
+            </Panel>
+            <Panel v-if="saving"><div class="empty-state">正在保存…</div></Panel>
+          </div>
+        </div>
+      </template>
+
+      <template v-if="step === 'knowledge'">
+        <DomainKnowledgeStep
+          :id="id"
+          :docs="m.docs || []"
+          :notify="notify"
+          :reload="() => load(true)"
+          :confirm="confirm"
+        />
+      </template>
+
+      <template v-if="step === 'assets'">
+        <div class="two-column">
+          <div>
+            <div class="kpi-row">
+              <div v-for="x in kpiData" :key="x[1]"><span>{{ x[1] }}</span><b>{{ x[0] }}</b><small>{{ x[2] }}</small></div>
+              <div v-if="saving"><span>保存</span><Status>保存中…</Status></div>
+            </div>
+            <Panel title="设备资产配置">
+              <template #tools><Button @click="addAssetRow">+ 添加设备类型</Button></template>
+              <UiTable class="data-table editable">
+                <thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>操作</th></tr></thead>
+                <tbody>
+                  <tr v-for="(row, i) in assetRows" :key="i" :class="!row.device_template_id ? 'empty-row' : ''">
+                    <td>
+                      <UiSelect :value="row.device_template_id || ''" @change="(e) => onAssetDeviceChange(i, e.target.value)">
+                        <option value="">选择设备模板…</option>
+                        <option v-for="d in allDevices" :key="d.id" :value="d.id">{{ d.name }}</option>
+                        <option value="__new__">+ 新增设备模板…</option>
+                      </UiSelect>
+                    </td>
+                    <td><UiInput class="number" type="number" min="0" :value="row.count || ''" placeholder="0" @change="(e) => updateAsset(i, { count: Number(e.target.value) })" /></td>
+                    <td><UiInput :value="row.rule" placeholder="例如:CH-##" @change="(e) => updateAsset(i, { rule: e.target.value })" /></td>
+                    <td><UiButton class="trash" @click="() => removeAssetRow(i)">⌫</UiButton></td>
+                  </tr>
+                </tbody>
+              </UiTable>
+              <div style="margin-top: 8px"><Button secondary @click="() => go('deviceTemplates')">查看全部设备模版</Button></div>
+            </Panel>
+            <Panel title="资产层级预览">
+              <div class="asset-tree-preview">
+                <div class="tree-root">⌄ <b>{{ m.name || "未命名方案" }}</b></div>
+                <div v-for="(row, i) in assetRowsForTree" :key="i" class="tree-branch">
+                  <div class="tree-node">⌄ {{ row.device_name || "(未选设备)" }} <span class="tree-count">{{ row.count }}</span></div>
+                  <div v-if="row.count > 0" class="tree-leaves">{{ formatInstances(row.rule, row.count) }}</div>
+                </div>
+              </div>
+            </Panel>
+          </div>
+          <div class="stack">
+            <Panel title="填写建议">
+              <ul class="tips">
+                <li>设备类型必须从设备模板库中选择,库中没有时点击下拉框「+ 新增设备模板…」现场创建。</li>
+                <li>为每种设备估算典型数量与命名规则。</li>
+                <li>命名规则用 <code>#</code> 表示序号,如 <code>CH-##</code> 生成 CH-01/CH-02。</li>
+                <li>点击「保存资产」才会写入;AI 补全会把建议设备追加在您已填项下方,库内缺失的模板会自动创建。</li>
+              </ul>
+            </Panel>
+            <Panel title="AI 智能补全">
+              <p class="hint">基于方案名称 / 领域 / 描述 / 补充描述,由 AI 分析并补充设备类型、数量与命名规则(位于您已填项下方)。</p>
+              <div style="display: flex; gap: 8px; align-items: center">
+                <UiButton class="button" :disabled="assetsCompleting" @click="runAssetsComplete">{{ assetsCompleting ? "补全中…" : "开始补全" }}</UiButton>
+                <UiButton class="text-button" @click="() => viewCompleteAssetsPrompt(id, m.name, domainName, m.description, m.supplement || '')">查看提示词</UiButton>
+              </div>
+              <div v-if="assetsCompleting || assetsLog" class="modal-console">
+                <div class="modal-console-head">AI 智能补全</div>
+                <pre class="modal-console-body" :ref="(el) => (assetsRef.value = el)">{{ assetsLog || "(等待 AI 分析…)" }}</pre>
+              </div>
+            </Panel>
+            <Panel v-if="saving"><div class="empty-state">正在保存…</div></Panel>
+          </div>
+        </div>
+      </template>
+
+      <template v-if="step === 'functions'">
+        <DomainFunctionsStep
+          :mu-tree="muTreeLocal"
+          :grouped="grouped"
+          :eff-selected="effSelected"
+          :on-toggle-mu="toggleMu"
+          :pages="pages"
+          :on-add-page="addPage"
+          :on-upload-page-json="uploadPageJson"
+          :on-save-page-meta="savePageMeta"
+          :on-delete-page="deletePage"
+          :on-generate-page="generatePage"
+          :on-download-page="downloadPage"
+          :on-preview-page="(p) => (previewPage = p)"
+          :on-generate-all="generateAllPages"
+          :gen-ids="genIds"
+          :gen-log="genLog"
+          :cubes-ai="cubesAi"
+          :on-run-cubes-complete="runCubesComplete"
+          :on-view-complete-cubes-prompt="() => viewCompleteCubesPrompt(id, m.name, domainName, m.description, m.supplement || '', assets.map((a) => ({ type: a.device_name, count: a.count, rule: a.rule })))"
+          :update-page-field="updatePageField"
+          :t-name="m.name"
+          :saving="saving"
+          :notify="notify"
+          :page-styles="pageStyles"
+          :style-id="m.style_id != null ? m.style_id : null"
+          :on-select-style="selectStyle"
+        />
+      </template>
+
+      <template v-if="step === 'opsflow'">
+        <Panel title="运维流程">
+          <div class="placeholder-block"><span class="badge todo">待开发</span><p>运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。</p></div>
+        </Panel>
+      </template>
+
+      <template v-if="step === 'generate'">
+        <DomainGenerateReview
+          :m="m"
+          :domain-name="domainName"
+          :assets="assets"
+          :eff-selected="effSelected"
+          :grouped="grouped"
+          :pages="pages"
+          :docs="m.docs || []"
+          :on-back="() => goStep('opsflow')"
+          :on-preview-page="(p) => (previewPage = p)"
+        />
+      </template>
+
+      <template v-if="step === 'expert'">
+        <Panel title="专家确认">
+          <div class="placeholder-block"><span class="badge todo">待开发</span><p>专家确认流程待开发,后续版本将支持领域专家在线评审并签署方案。</p></div>
+        </Panel>
+      </template>
+    </div>
+
+    <div class="sticky-actions">
+      <Button secondary @click="curIdx > 0 ? goStep(DOMAIN_STEPS[curIdx - 1].key) : onBack()">{{ curIdx > 0 ? "上一步" : "返回列表" }}</Button>
+      <div>
+        <Button secondary @click="() => notify('草稿已保存')">保存草稿</Button>
+        <Button v-if="step === 'generate'" @click="() => (pkgOpen = true)">📦 打包下载</Button>
+        <Button v-if="step !== 'generate' && curIdx < DOMAIN_STEPS.length - 1" @click="() => goStep(DOMAIN_STEPS[curIdx + 1].key)">下一步</Button>
+        <Button v-if="curIdx === DOMAIN_STEPS.length - 1" @click="() => notify('方案已提交专家确认')">提交确认</Button>
+      </div>
+    </div>
+
+    <PackageModal v-if="pkgOpen" :name="m.name" :on-close="() => (pkgOpen = false)" :notify="notify" />
+  </template>
+</template>
diff --git a/src-vue/pages/domain/ImportDomainPackage.vue b/src-vue/pages/domain/ImportDomainPackage.vue
new file mode 100644 (file)
index 0000000..67e2ad2
--- /dev/null
@@ -0,0 +1,111 @@
+<script setup>
+import { ref, watch, onMounted } from "vue";
+import { UploadOutlined } from "@ant-design/icons-vue";
+import Panel from "../../components/Panel.vue";
+import Field from "../../components/Field.vue";
+import UiInput from "../../ui/UiInput.vue";
+import UiSelect from "../../ui/UiSelect.vue";
+import UiActions from "../../ui/UiActions.vue";
+import Button from "../../components/PrimaryButton.vue";
+import { fetchDb } from "../../lib/api.js";
+import { friendlyDomainName } from "./domainCommon.js";
+
+const CURRENT_USER_EMAIL = "wang.yu@advantech.com";
+
+const props = defineProps({
+  doms: { type: Array, default: () => [] },
+  notify: { type: Function, default: () => {} },
+  onImported: { type: Function, default: () => {} },
+});
+
+const file = ref(null);
+const domainId = ref(0);
+const name = ref("");
+const version = ref("1.0");
+const description = ref("");
+const busy = ref(false);
+
+onMounted(() => {
+  if (!domainId.value && props.doms.length) domainId.value = Number(props.doms[0].id);
+});
+watch(
+  () => [props.doms, domainId.value],
+  () => {
+    if (!domainId.value && props.doms.length) domainId.value = Number(props.doms[0].id);
+  }
+);
+
+function choose(picked) {
+  if (!picked) return;
+  if (!/\.zip$/i.test(picked.name)) {
+    props.notify("请选择 ZIP 格式的领域方案包");
+    return;
+  }
+  file.value = picked;
+  if (!name.value) name.value = picked.name.replace(/\.zip$/i, "").replace(/[-_]/g, " ");
+}
+
+async function submit() {
+  if (!file.value) {
+    props.notify("请先选择领域方案 ZIP 包");
+    return;
+  }
+  if (!domainId.value || !name.value.trim()) {
+    props.notify("请填写方案名称和所属领域");
+    return;
+  }
+  busy.value = true;
+  try {
+    const row = await fetchDb("/domain-templates", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        domain_id: domainId.value,
+        name: name.value.trim(),
+        description: description.value.trim() || `由 ${file.value.name} 人工导入的领域方案`,
+        status: "available",
+        version: version.value.trim() || "1.0",
+        creator: CURRENT_USER_EMAIL,
+      }),
+    });
+    props.notify("领域方案导入完成,可继续维护各项能力");
+    props.onImported(Number(row.id));
+  } catch (e) {
+    props.notify("导入失败:" + (e && e.message ? e.message : e));
+  } finally {
+    busy.value = false;
+  }
+}
+</script>
+
+<template>
+  <div class="domain-import-page">
+    <Panel title="导入领域方案">
+      <div class="domain-import-layout">
+        <label :class="'domain-import-drop' + (file ? ' selected' : '')">
+          <UploadOutlined />
+          <b>{{ file ? file.name : "选择领域方案 ZIP 包" }}</b>
+          <span>{{ file ? `${(file.size / 1024).toFixed(1)} KB · 点击可重新选择` : "将客户现场已有的资产、页面、知识和菜单配置导入 StationBuilder" }}</span>
+          <input type="file" accept=".zip,application/zip" hidden @change="(e) => choose(e.target.files && e.target.files[0] ? e.target.files[0] : null)" />
+        </label>
+        <div class="domain-import-form">
+          <Field label="方案名称" required><UiInput :value="name" @change="(e) => (name = e.target.value)" placeholder="例如:AIDC 标准场站方案" /></Field>
+          <Field label="所属领域" required>
+            <UiSelect :value="domainId" @change="(e) => (domainId = Number(e.target.value))">
+              <option value="0">请选择领域</option>
+              <option v-for="d in doms" :key="d.id" :value="d.id">{{ friendlyDomainName(d.name) }}</option>
+            </UiSelect>
+          </Field>
+          <Field label="方案版本"><UiInput :value="version" @change="(e) => (version = e.target.value)" placeholder="1.0" /></Field>
+          <Field label="导入说明"><UiInput textarea :rows="4" :value="description" @change="(e) => (description = e.target.value)" placeholder="说明方案来源、适用场景或需要现场调整的内容" /></Field>
+        </div>
+      </div>
+      <div class="domain-package-check">
+        <b>方案包内容</b><span>资产配置</span><span>仪表板与 SC 页面</span><span>百科与文档</span><span>菜单配置</span>
+      </div>
+      <UiActions class="domain-import-actions">
+        <Button :disabled="busy || !file" @click="submit">{{ busy ? "正在导入…" : "开始导入" }}</Button>
+      </UiActions>
+    </Panel>
+  </div>
+</template>
diff --git a/src-vue/pages/domain/PackageModal.vue b/src-vue/pages/domain/PackageModal.vue
new file mode 100644 (file)
index 0000000..3fadf85
--- /dev/null
@@ -0,0 +1,56 @@
+<script setup>
+import UiOverlay from "../../ui/UiOverlay.vue";
+import UiButton from "../../ui/UiButton.vue";
+import Button from "../../components/PrimaryButton.vue";
+
+const props = defineProps({
+  name: { type: String, default: "" },
+  onClose: { type: Function, default: () => {} },
+  notify: { type: Function, default: () => {} },
+});
+
+const items = [
+  { icon: "⬡", label: "设备模板", desc: "本方案选用的设备模版及采集点" },
+  { icon: "▦", label: "资产结构", desc: "默认资产组与设备实例层级" },
+  { icon: "❖", label: "扩展页面文件", desc: "已生成的 Cube 扩展页面文件" },
+  { icon: "☰", label: "默认菜单", desc: "MU + 综合监控 菜单结构" },
+  { icon: "📄", label: "领域知识", desc: "上传的领域知识文档" },
+];
+</script>
+
+<template>
+  <UiOverlay class="modal-backdrop" @mousedown="onClose">
+    <div class="modal pkg-modal" @mousedown.stop>
+      <header>
+        <h2>方案打包</h2>
+        <UiButton @click="onClose">×</UiButton>
+      </header>
+      <div class="modal-body pkg-body">
+        <p class="hint">即将把方案「{{ name || '未命名方案' }}」打包为可交付成果,包含以下组成部分:</p>
+        <div class="pkg-box">
+          <svg viewBox="0 0 260 150" class="pkg-svg" role="img" aria-label="方案打包箱">
+            <polygon points="50,70 130,52 210,70 130,88" fill="#d9a05b" stroke="#b9823f" stroke-width="1.5" />
+            <polygon points="50,70 50,118 130,136 130,88" fill="#c98f4c" stroke="#b9823f" stroke-width="1.5" />
+            <polygon points="210,70 210,118 130,136 130,88" fill="#e0aa63" stroke="#b9823f" stroke-width="1.5" />
+            <polygon points="50,70 130,52 130,30 50,48" fill="#e7b877" stroke="#b9823f" stroke-width="1.5" />
+            <polygon points="210,70 130,52 130,30 210,48" fill="#e7b877" stroke="#b9823f" stroke-width="1.5" />
+            <rect x="92" y="60" width="76" height="10" rx="2" fill="#fff" opacity=".9" />
+            <rect x="96" y="50" width="68" height="10" rx="2" fill="#eef7fb" opacity=".95" />
+            <rect x="100" y="40" width="60" height="10" rx="2" fill="#fff" opacity=".9" />
+            <rect x="104" y="31" width="52" height="9" rx="2" fill="#e6f3fb" opacity=".95" />
+          </svg>
+        </div>
+        <div class="pkg-items">
+          <div v-for="it in items" :key="it.label" class="pkg-item">
+            <span class="pkg-icon">{{ it.icon }}</span>
+            <div class="pkg-item-text"><b>{{ it.label }}</b><small>{{ it.desc }}</small></div>
+          </div>
+        </div>
+      </div>
+      <footer>
+        <Button secondary @click="onClose">取消</Button>
+        <Button @click="() => { notify('方案已打包(示意),可交付给实施团队'); onClose(); }">确认打包下载</Button>
+      </footer>
+    </div>
+  </UiOverlay>
+</template>
diff --git a/src-vue/pages/domain/StyleEditorModal.vue b/src-vue/pages/domain/StyleEditorModal.vue
new file mode 100644 (file)
index 0000000..6c0f831
--- /dev/null
@@ -0,0 +1,173 @@
+<script setup>
+import { ref, watch, onMounted } from "vue";
+import UiOverlay from "../../ui/UiOverlay.vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiInput from "../../ui/UiInput.vue";
+import Panel from "../../components/Panel.vue";
+import Field from "../../components/Field.vue";
+import Button from "../../components/PrimaryButton.vue";
+import { fetchDb } from "../../lib/api.js";
+import { useConfirm } from "./useConfirm.js";
+
+const props = defineProps({
+  id: { type: Number, default: undefined },
+  notify: { type: Function, default: () => {} },
+  afterChange: { type: Function, default: () => {} },
+  onClose: { type: Function, default: () => {} },
+});
+
+const { confirm, el: confirmEl } = useConfirm();
+
+const name = ref("");
+const jsonText = ref("");
+const image = ref(null);
+const imageUrl = ref(props.id ? `/api/db/page-styles/${props.id}/image?w=640` : null);
+const saving = ref(false);
+
+function readText(file) {
+  return new Promise((resolve, reject) => {
+    const r = new FileReader();
+    r.onload = () => resolve(String(r.result));
+    r.onerror = () => reject(r.error);
+    r.readAsText(file);
+  });
+}
+function readImage(file) {
+  return new Promise((resolve, reject) => {
+    const r = new FileReader();
+    r.onload = () => resolve({ filename: file.name, data: String(r.result).split(",")[1] });
+    r.onerror = () => reject(r.error);
+    r.readAsDataURL(file);
+  });
+}
+
+onMounted(async () => {
+  if (props.id) {
+    try {
+      const row = await fetchDb(`/page-styles/${props.id}`);
+      name.value = row.name || "";
+      jsonText.value = row.json_text || "";
+    } catch (e) {
+      props.notify("加载样式失败: " + (e && e.message ? e.message : e));
+    }
+  }
+});
+
+async function onPickJson(e) {
+  const f = e.target.files && e.target.files[0];
+  if (!f) return;
+  try {
+    jsonText.value = await readText(f);
+  } catch (err) {
+    props.notify("读取文件失败");
+  }
+}
+async function onPickImage(e) {
+  const f = e.target.files && e.target.files[0];
+  if (!f) return;
+  try {
+    const img = await readImage(f);
+    image.value = img;
+    imageUrl.value = URL.createObjectURL(f);
+  } catch (err) {
+    props.notify("读取图片失败");
+  }
+}
+
+async function submit() {
+  if (!name.value.trim()) {
+    props.notify("请填写样式名称");
+    return;
+  }
+  if (!jsonText.value.trim()) {
+    props.notify("请填写或上传 JSON 内容");
+    return;
+  }
+  try {
+    JSON.parse(jsonText.value);
+  } catch (err) {
+    props.notify("JSON 内容不是合法 JSON");
+    return;
+  }
+  if (saving.value) return;
+  saving.value = true;
+  try {
+    const body = { name: name.value.trim(), jsonText: jsonText.value };
+    if (image.value) body.image = image.value;
+    if (props.id) {
+      await fetchDb(`/page-styles/${props.id}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      });
+    } else {
+      if (!image.value) {
+        props.notify("请上传预览图");
+        saving.value = false;
+        return;
+      }
+      await fetchDb(`/page-styles`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      });
+    }
+    await props.afterChange(props.id ? "样式已更新" : "样式已新增");
+  } catch (e) {
+    props.notify("保存失败: " + (e && e.message ? e.message : e));
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function remove() {
+  if (!props.id) return;
+  if (!(await confirm("确定删除该样式?此操作不可恢复"))) return;
+  try {
+    await fetchDb(`/page-styles/${props.id}`, { method: "DELETE" });
+    await props.afterChange("样式已删除");
+  } catch (e) {
+    props.notify("删除失败: " + (e && e.message ? e.message : e));
+  }
+}
+</script>
+
+<template>
+  <UiOverlay class="modal-backdrop" @mousedown="onClose">
+    <component :is="confirmEl" />
+    <div class="modal" style="width: 640px" @mousedown.stop>
+      <header>
+        <h2>{{ id ? "编辑页面样式" : "新增页面样式" }}</h2>
+        <UiButton @click="onClose">×</UiButton>
+      </header>
+      <div class="modal-body">
+        <div class="modal-note">页面样式包含一份 Cube 仪表盘 JSON(配色与视觉主题)和一张预览图。名称用于在样式库中展示。</div>
+        <Field label="样式名称" required><UiInput :value="name" @change="(e) => (name = e.target.value)" placeholder="例如:冰川浅蓝" /></Field>
+        <Field label="预览图" :required="!id" hint="建议 16:10 比例的 PNG/JPG,将作为缩略图展示">
+          <div class="style-upload-row">
+            <label class="upload" style="flex: 1; height: 120px">
+              <b>+</b><span>{{ image ? "已选择新图片" : "选择预览图" }}</span>
+              <input type="file" accept="image/*" hidden @change="onPickImage" />
+            </label>
+            <div v-if="imageUrl" class="style-upload-prev"><img :src="imageUrl" alt="预览" /></div>
+          </div>
+        </Field>
+        <Field label="仪表盘 JSON" required hint="可上传 Cube 仪表盘 JSON 文件,或直接粘贴内容(需为合法 JSON)">
+          <div class="style-json-row">
+            <UiInput textarea :value="jsonText" @change="(e) => (jsonText = e.target.value)" placeholder='{"name":"...","grid":[...],"panels":[...]}' />
+            <label class="upload" style="width: 120px; height: 64px">
+              <b>↑</b><span>上传 JSON</span>
+              <input type="file" accept=".json,application/json" hidden @change="onPickJson" />
+            </label>
+          </div>
+        </Field>
+      </div>
+      <footer>
+        <Button v-if="id" secondary @click="remove" title="删除该样式">删除</Button>
+        <span class="spacer" />
+        <Button secondary @click="onClose">取消</Button>
+        <Button :disabled="saving" @click="submit">{{ saving ? "保存中…" : "保存" }}</Button>
+      </footer>
+    </div>
+  </UiOverlay>
+</template>
diff --git a/src-vue/pages/domain/StylePreview.vue b/src-vue/pages/domain/StylePreview.vue
new file mode 100644 (file)
index 0000000..f2c4a76
--- /dev/null
@@ -0,0 +1,23 @@
+<script setup>
+import UiOverlay from "../../ui/UiOverlay.vue";
+import UiButton from "../../ui/UiButton.vue";
+
+const props = defineProps({
+  style: { type: Object, required: true },
+  onClose: { type: Function, default: () => {} },
+});
+</script>
+
+<template>
+  <UiOverlay class="modal-backdrop" @mousedown="onClose">
+    <div class="modal" style="width: auto; max-width: 92vw" @mousedown.stop>
+      <header>
+        <h2>{{ style.name }}</h2>
+        <UiButton @click="onClose">×</UiButton>
+      </header>
+      <div class="modal-body">
+        <img class="style-lightbox" :src="`/api/db/page-styles/${style.id}/image`" :alt="style.name" />
+      </div>
+    </div>
+  </UiOverlay>
+</template>
diff --git a/src-vue/pages/domain/domainCommon.js b/src-vue/pages/domain/domainCommon.js
new file mode 100644 (file)
index 0000000..ec1455f
--- /dev/null
@@ -0,0 +1,116 @@
+// Shared helpers for the domain pages, ported faithfully from app/page.tsx.
+// Pure helpers live here; components needing them import from this module.
+
+import { ref, watch } from "vue";
+import wastewaterPhoto from "../../../src/assets/solutions/wastewater-600x300.webp";
+import datacenterPhoto from "../../../src/assets/solutions/datacenter-600x300.webp";
+import energyPhoto from "../../../src/assets/solutions/energy-600x300.webp";
+
+// ---- app/page.tsx:2606 ----
+export function friendlyDomainName(name) {
+  return /数据中心|AIDC/i.test(name || "") ? "AIDC 数据中心" : name || "其他领域";
+}
+
+// ---- app/page.tsx:2610 ----
+export function domainScene(domain) {
+  if (/数据中心|AIDC/i.test(domain))
+    return {
+      code: "AIDC",
+      tone: "violet",
+      desc: "面向数据中心基础设施的资产、监控、能效与运维能力。",
+      twins: ["机房三维总览", "冷源系统孪生"],
+      wiki: ["数据中心基础设施百科", "制冷与供配电设备说明"],
+    };
+  if (/水务|污水|自来水/i.test(domain))
+    return {
+      code: "WATER",
+      tone: "blue",
+      desc: "覆盖水处理工艺、泵站设备、运行监控与标准运维资料。",
+      twins: ["厂区工艺流程孪生", "泵房设备场景"],
+      wiki: ["水处理工艺百科", "泵阀与仪表设备说明"],
+    };
+  if (/新能源|储能|光伏/i.test(domain))
+    return {
+      code: "ENERGY",
+      tone: "green",
+      desc: "覆盖储能与新能源场站的设备、运行页面和安全管理规则。",
+      twins: ["储能电站全景", "电池舱运行孪生"],
+      wiki: ["储能系统百科", "电池与变流器设备说明"],
+    };
+  return {
+    code: "SITE",
+    tone: "gray",
+    desc: "沉淀该领域可复用的资产、页面、知识和菜单配置。",
+    twins: ["场站三维总览"],
+    wiki: ["领域基础百科", "设备与工艺说明"],
+  };
+}
+
+// ---- app/page.tsx:2617 ----
+export const SOLUTION_PHOTOS = { WATER: wastewaterPhoto, AIDC: datacenterPhoto, ENERGY: energyPhoto };
+
+// ---- app/page.tsx:1936 ----
+export const DOMAIN_STEPS = [
+  { key: "basic", label: "方案基本属性" },
+  { key: "knowledge", label: "领域知识" },
+  { key: "assets", label: "资产设置" },
+  { key: "functions", label: "功能与菜单" },
+  { key: "opsflow", label: "运维流程" },
+  { key: "generate", label: "方案打包" },
+  { key: "expert", label: "专家确认" },
+];
+
+// ---- app/page.tsx:95 (Status tone) ----
+export function statusTone(v) {
+  const s = String(v);
+  if (/不可用|失败|异常|failed/.test(s)) return "bad";
+  if (/完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(s)) return "ok";
+  if (/生成中|校验中|告警|running/.test(s)) return "warn";
+  return "muted";
+}
+
+// ---- app/page.tsx:1964 ----
+export async function streamInto(res, setLog, onEv) {
+  const reader = res.body.getReader();
+  const dec = new TextDecoder();
+  let buf = "";
+  for (;;) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    buf += dec.decode(value, { stream: true });
+    const lines = buf.split("\n");
+    buf = lines.pop() || "";
+    for (const line of lines) {
+      if (!line.trim()) continue;
+      try {
+        const ev = JSON.parse(line);
+        if (ev.log) setLog((prev) => prev + ev.log);
+        onEv(ev);
+      } catch (e) {
+        /* ignore malformed line */
+      }
+    }
+  }
+}
+
+// ---- app/page.tsx:1956 ----
+export function useStreamComplete() {
+  const completing = ref(false);
+  const log = ref("");
+  const el = ref(null);
+  watch(log, () => {
+    const node = el.value;
+    if (node) node.scrollTop = node.scrollHeight;
+  });
+  return {
+    completing,
+    setCompleting: (v) => {
+      completing.value = v;
+    },
+    log,
+    setLog: (v) => {
+      log.value = typeof v === "function" ? v(log.value) : v;
+    },
+    ref: el,
+  };
+}
diff --git a/src-vue/pages/domain/useConfirm.js b/src-vue/pages/domain/useConfirm.js
new file mode 100644 (file)
index 0000000..dc0a1cc
--- /dev/null
@@ -0,0 +1,59 @@
+// Faithful port of React useConfirm() from app/page.tsx:1498.
+// Returns { confirm, el } where `el` is a Vue component (render-fn) to mount.
+import { ref, h } from "vue";
+import UiOverlay from "../../ui/UiOverlay.vue";
+import UiButton from "../../ui/UiButton.vue";
+import UiActions from "../../ui/UiActions.vue";
+
+export function useConfirm() {
+  const state = ref({ open: false, message: "", resolve: null });
+
+  const confirm = (message) =>
+    new Promise((resolve) => {
+      state.value = { open: true, message, resolve };
+    });
+
+  const close = (v) => {
+    if (state.value.resolve) state.value.resolve(v);
+    state.value = { ...state.value, open: false };
+  };
+
+  const ConfirmEl = {
+    setup() {
+      return () =>
+        state.value.open
+          ? h(
+              UiOverlay,
+              { class: "dlg-backdrop", onClick: () => close(false) },
+              () => [
+                h(
+                  "div",
+                  { class: "dlg-box", onClick: (e) => e.stopPropagation() },
+                  [
+                    h("p", state.value.message),
+                    h(
+                      UiActions,
+                      { class: "dlg-actions" },
+                      () => [
+                        h(
+                          UiButton,
+                          { class: "button secondary", onClick: () => close(false) },
+                          () => "取消"
+                        ),
+                        h(
+                          UiButton,
+                          { class: "button", onClick: () => close(true) },
+                          () => "确定"
+                        ),
+                      ]
+                    ),
+                  ]
+                ),
+              ]
+            )
+          : null;
+    },
+  };
+
+  return { confirm, el: ConfirmEl };
+}
diff --git a/src-vue/site-menu-model.js b/src-vue/site-menu-model.js
new file mode 100644 (file)
index 0000000..acbb024
--- /dev/null
@@ -0,0 +1,115 @@
+export const APPLICATION_COLOR_SCHEMES = [
+  { key: "blue", label: "蓝色", color: "#009fe3" },
+  { key: "green", label: "绿色", color: "#23a77b" },
+  { key: "orange", label: "橙色", color: "#ed8b2c" },
+  { key: "purple", label: "紫色", color: "#8860c3" },
+  { key: "red", label: "红色", color: "#df5c5c" },
+];
+
+export function effectiveStandardPageKeys(site, leaves) {
+  const selected = site?.mu || [];
+  if (selected.length || site?.page_config?.standard_pages_initialized) return selected;
+  return leaves.filter((leaf) => !leaf.disabled).map((leaf) => leaf.key);
+}
+
+export function selectedMenuSources(site, leaves) {
+  const twins = site?.page_config?.twin_ids || [];
+  const cubeIds = site?.page_config?.dashboard_ids ?? (site?.cubes || []).map((cube) => cube.id);
+  const standardKeys = effectiveStandardPageKeys(site, leaves);
+  return [
+    ...twins.map((id) => ({
+      key: `sc:${id}`,
+      name: id === "site-overview" ? "场站全景" : id === "process-scene" ? "工艺与设备场景" : id,
+      kind: "sc",
+      ref: id,
+      path: ["数字孪生"],
+    })),
+    ...(site?.cubes || [])
+      .filter((cube) => cubeIds.includes(cube.id))
+      .map((cube) => ({ key: `cube:${cube.id}`, name: cube.name, kind: "cube", ref: cube.id, path: ["领域监控"] })),
+    ...leaves
+      .filter((leaf) => !leaf.disabled && standardKeys.includes(leaf.key))
+      .map((leaf) => ({ key: `mu:${leaf.key}`, name: leaf.label, kind: "mu", ref: leaf.key, path: leaf.parentMenu ? leaf.parentMenu.split("/") : [] })),
+  ];
+}
+
+export function buildSiteMenu(site, leaves, saved = site?.menu || []) {
+  const result = [];
+  const used = new Set();
+  const groups = new Map();
+  const findSaved = (key, fallback) =>
+    saved.find((item) => !used.has(item.id) && item.source_key === key) ||
+    saved.find((item) => !used.has(item.id) && !item.source_key && fallback(item));
+  const addGroup = (key, name, parent, locked = false) => {
+    if (groups.has(key)) return groups.get(key);
+    const aliases = key === "group:sc" ? ["数字孪生", "数字孪生场景"] : key === "group:cube" ? ["领域监控", "领域仪表板"] : [name];
+    const existing = findSaved(key, (item) => !item.page_type && aliases.includes(item.label));
+    const entry = {
+      id: existing?.id ?? key,
+      parent_id: parent,
+      label: locked ? name : existing?.label || name,
+      page_type: null,
+      ref_id: null,
+      source_key: key,
+      source_name: name,
+    };
+    used.add(entry.id);
+    groups.set(key, entry);
+    result.push(entry);
+    return entry;
+  };
+  const sc = addGroup("group:sc", "数字孪生", null, true);
+  const cube = addGroup("group:cube", "领域监控", null, true);
+  const sources = selectedMenuSources(site, leaves);
+  const dynamic = sources.filter((source) => source.kind !== "mu");
+  dynamic.sort((a, b) => {
+    if (a.kind !== b.kind) return a.kind === "sc" ? -1 : 1;
+    const rank = (source) => {
+      const index = saved.findIndex((item) => item.source_key === source.key || (item.page_type === source.kind && String(item.ref_id) === String(source.ref)));
+      return index < 0 ? Number.MAX_SAFE_INTEGER : index;
+    };
+    return rank(a) - rank(b);
+  });
+  for (const source of [...dynamic, ...sources.filter((item) => item.kind === "mu")]) {
+    let parent = source.kind === "sc" ? sc.id : source.kind === "cube" ? cube.id : null;
+    if (source.kind === "mu") {
+      let path = "";
+      for (const segment of source.path) {
+        path += `/${segment}`;
+        parent = addGroup(`group:mu:${path}`, segment, parent).id;
+      }
+    }
+    const existing = findSaved(source.key, (item) => item.page_type === source.kind && String(item.ref_id) === String(source.ref));
+    const entry = {
+      id: existing?.id ?? source.key,
+      parent_id: parent,
+      label: existing?.label || source.name,
+      page_type: source.kind,
+      ref_id: source.ref,
+      source_key: source.key,
+      source_name: source.name,
+    };
+    used.add(entry.id);
+    result.push(entry);
+  }
+  return orderMenu(result);
+}
+
+export function orderMenu(menu) {
+  const result = [];
+  const used = new Set();
+  const walk = (parent) => {
+    for (const item of menu.filter((item) => item.parent_id === parent)) {
+      if (used.has(item.id)) continue;
+      used.add(item.id);
+      result.push(item);
+      walk(item.id);
+    }
+  };
+  walk(null);
+  return result;
+}
+
+export function menuSelectionSignature(site, leaves) {
+  return JSON.stringify(selectedMenuSources(site, leaves).map((source) => [source.key, source.name, source.path]));
+}
diff --git a/src-vue/styles/antd.less b/src-vue/styles/antd.less
new file mode 100644 (file)
index 0000000..7b82d3e
--- /dev/null
@@ -0,0 +1,88 @@
+/* antd v6 兜底覆盖
+ *
+ * 这是 L0 的第 5 层:token 与 CSS 变量都表达不了、只能靠选择器命中的覆盖。
+ * 尽量少写 —— 内部选择器依赖 v6 的 DOM 结构,大版本升级即可能失效。
+ *
+ * 能改 token 的 → src/theme.json
+ * 能改变量或只是渐变的 → src/theme/overrides.css
+ *
+ * 已知待收窄项(中性灰暂无对应 antd token):#EDEDED / #FAFAFA / #E1E1E1 / #89898A
+ */
+
+// Breadcrumb:token 里没有字重项
+.ant-breadcrumb {
+  .ant-breadcrumb-item {
+    font-weight: 700;
+  }
+}
+
+// Tabs 公共样式
+.ant-tabs {
+  background: #EDEDED;
+  font-family: var(--ant-font-family);
+
+  .ant-tabs-nav {
+    margin-bottom: 0;
+
+    &::before {
+      border-bottom: 1px solid #E1E1E1;
+    }
+  }
+
+  &.ant-tabs-card {
+    .ant-tabs-tab {
+      padding: 12px 24px;
+      background: #EDEDED;
+      border-bottom: 1px solid #E1E1E1;
+
+      .ant-tabs-tab-btn {
+        border: none;
+        font-weight: 700;
+
+        &>span {
+          font-size: 1rem;
+          line-height: 24px;
+          font-weight: 700;
+          color: #89898A;
+        }
+      }
+
+      &.ant-tabs-tab-active {
+        background: var(--ant-color-bg-container);
+        border: 1px solid #E1E1E1;
+        border-bottom-color: transparent;
+
+        .ant-tabs-tab-btn {
+          &>span {
+            color: var(--ant-color-primary);
+          }
+        }
+      }
+    }
+  }
+
+  .ant-tabs-content {
+    background: #FAFAFA;
+  }
+
+  // Tabs 中数据条目数量应用此类名
+  .count-badge {
+    background-color: #e1e1e1;
+    border: 1px solid #c8c8c8;
+    color: #c8c8c8;
+    border-radius: 9999px;
+    font-size: 12px;
+    min-width: 16px;
+    line-height: 16px;
+    font-weight: 700;
+    padding: 0 4px;
+    box-sizing: border-box;
+    margin-left: 8px;
+
+    &.active {
+      background-color: #b6f0d3;
+      border: 1px solid var(--ant-color-success);
+      color: var(--ant-color-success);
+    }
+  }
+}
diff --git a/src-vue/styles/brand-layout.css b/src-vue/styles/brand-layout.css
new file mode 100644 (file)
index 0000000..15a6a77
--- /dev/null
@@ -0,0 +1,52 @@
+/* StationBuilder business layout on top of the Skill's unmodified brand files.
+   Keep the approved WISE-IoT header artwork and the existing configuration flow. */
+.sb-brand-shell.app-shell { padding-left:0; }
+.sb-brand-shell>.body { min-width:0; }
+.sb-brand-shell .sb-brand-nav { position:sticky; top:56px; align-self:flex-start; height:calc(100dvh - 56px); display:flex; flex-direction:column; background:var(--ant-color-border); overflow:auto; }
+.sb-brand-nav>nav { flex:1; min-height:0; }
+.sb-brand-nav>nav>.ant-menu { height:auto; min-height:100%; font-family:var(--ant-font-family); font-size:var(--ant-font-size); }
+.sb-brand-nav .ant-menu-item,.sb-brand-nav .ant-menu-submenu-title { font-weight:400; }
+.sb-brand-nav>.design-doc-link { position:static; margin:var(--ant-padding-sm) var(--ant-padding); height:var(--ant-control-height); flex-shrink:0; border-radius:var(--ant-border-radius); background:var(--ant-color-primary); color:var(--ant-color-text-light-solid); }
+.sb-brand-shell .workspace.main { display:flex; flex-direction:column; padding:0; min-height:calc(100dvh - 56px); }
+.sb-brand-shell .workspace>.page-heading { padding:var(--ant-padding) var(--ant-padding-lg); min-height:80px; background:var(--ant-color-bg-layout); gap:var(--ant-padding); }
+.sb-brand-shell .page-heading h1 { font-size:var(--ant-font-size-heading-4); line-height:var(--ant-line-height-heading-4); font-weight:var(--ant-font-weight-strong); }
+.sb-brand-shell .page-heading p { font-size:var(--ant-font-size-sm); line-height:var(--ant-line-height-sm); margin-top:var(--ant-padding-xxs); color:var(--ant-color-text-secondary); }
+.sb-brand-shell .page-heading>div:last-child { display:flex; flex-wrap:nowrap; align-items:center; gap:var(--ant-padding-xs); }
+.sb-brand-shell .workspace>.content { flex:1; min-width:0; padding:var(--ant-padding) var(--ant-padding-lg) var(--ant-padding-lg); }
+.sb-brand-shell .workspace>.sticky-actions { position:sticky; inset:auto; bottom:0; width:100%; min-height:64px; height:auto; margin:0; padding:var(--ant-padding) var(--ant-padding-lg); border-top:1px solid var(--ant-color-border); background:var(--ant-color-bg-container); z-index:20; }
+.sb-brand-shell .station-config-footer,.sb-brand-shell .sticky-actions>div { flex-wrap:nowrap; gap:var(--ant-padding-xs); }
+.sb-brand-shell .filterbar.company-filterbar,.sb-brand-shell .station-list-toolbar { display:flex; align-items:center; gap:var(--ant-padding-xs); padding-block:var(--ant-padding); min-height:64px; height:auto; }
+.sb-brand-shell .station-list-toolbar>div:last-child,.sb-brand-shell .filter-actions { margin-left:auto; display:flex; align-items:center; flex-wrap:nowrap; gap:var(--ant-padding-xs); }
+.sb-brand-shell .search-input.ant-input-affix-wrapper { flex-shrink:0; max-width:100%; height:var(--ant-control-height); }
+.sb-brand-shell .page-footer>.pagination { height:auto; min-height:0; padding:0; margin:0; border:0; background:transparent; }
+.sb-brand-shell .page-footer .page-size { min-width:128px; }
+.sb-brand-shell .station-ui-table .ant-table-cell { height:auto; line-height:var(--ant-line-height); padding-inline:var(--ant-padding); }
+.sb-brand-shell .station-ui-table .ant-table-thead>tr>th { padding-block:var(--ant-padding-sm); text-align:left; border-bottom:2px solid var(--ant-color-primary); }
+.sb-brand-shell .station-ui-table .ant-table-tbody>tr>td { padding-block:var(--ant-table-cell-padding-block); }
+.sb-brand-shell .panel.ant-card>.ant-card-head.panel-head { display:block; min-height:48px; padding:0 var(--ant-padding-lg); }
+.sb-brand-shell .panel.ant-card .ant-card-head-wrapper { gap:var(--ant-padding); }
+.sb-brand-shell .panel.ant-card .ant-card-head-title { font-size:var(--ant-font-size); font-weight:var(--ant-font-weight-strong); }
+.sb-brand-shell .panel.ant-card>.ant-card-body { padding:var(--ant-padding-lg); }
+.sb-brand-shell .company-list-panel.ant-card>.ant-card-body { padding:0 var(--ant-padding) var(--ant-padding-sm); }
+.sb-brand-shell .station-ui-tabs>.ant-tabs-nav { margin:0; background:var(--ant-color-fill-quaternary); }
+.sb-brand-shell .station-ui-tabs>.ant-tabs-nav .ant-tabs-tab { padding:var(--ant-padding-sm) var(--ant-padding-lg); }
+.sb-brand-shell .station-ui-tabs>.ant-tabs-content-holder { background:var(--ant-color-bg-layout); }
+.sb-brand-shell .station-ui-tabs .ant-tabs-tab-btn b,.sb-brand-shell .station-ui-tabs .ant-tabs-tab-btn i { display:inline-flex; align-items:center; min-width:16px; line-height:16px; padding:0 4px; margin-left:8px; border:1px solid var(--ant-color-border); border-radius:9999px; }
+.sb-brand-shell .station-ui-tabs .ant-tabs-tab-active .ant-tabs-tab-btn b { border-color:var(--ant-color-success); }
+.sb-brand-shell .domain-capability-grid { gap:var(--ant-padding-sm); }
+.sb-brand-shell .domain-capability-detail { height:calc(100dvh - 176px); min-height:800px; }
+.sb-brand-shell .domain-capability-grid { flex:1; min-height:0; }
+.sb-brand-shell .domain-capability-card { height:100%; min-height:0; max-height:none; }
+.sb-brand-shell .domain-capability-card>footer { padding:var(--ant-padding-xs); min-height:48px; flex-wrap:nowrap; }
+.sb-brand-shell .domain-capability-card>footer .ant-btn { padding-inline:var(--ant-padding-xs); }
+.sb-brand-shell .dashboard-manager-actions { display:flex; flex-wrap:nowrap; align-items:center; gap:var(--ant-padding-xs); }
+@media (max-width:1440px) {
+  .sb-brand-shell .domain-capability-detail { height:auto; min-height:0; }
+  .sb-brand-shell .domain-capability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
+  .sb-brand-shell .domain-capability-card { height:520px; min-height:0; }
+}
+@media (max-width:1000px) {
+  .sb-brand-shell .workspace>.content { padding:var(--ant-padding); }
+  .sb-brand-shell .station-list-toolbar,.sb-brand-shell .filterbar.company-filterbar { overflow-x:auto; }
+  .sb-brand-shell .domain-capability-grid { grid-template-columns:1fr; }
+}
diff --git a/src-vue/styles/globals.css b/src-vue/styles/globals.css
new file mode 100644 (file)
index 0000000..5dc49da
--- /dev/null
@@ -0,0 +1,579 @@
+@import "tailwindcss";
+*{box-sizing:border-box}html,body{margin:0;min-height:100%;font-family:var(--ant-font-family);color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout)}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}.app-shell{min-height:100vh;padding-top:56px;padding-left:192px}.topbar{position:fixed;z-index:50;left:0;right:0;top:0;height:56px;border-top:3px solid transparent;background-image:linear-gradient(var(--ant-color-bg-container),var(--ant-color-bg-container)),linear-gradient(90deg,var(--ant-color-primary),var(--ant-color-primary));background-origin:border-box;background-clip:padding-box,border-box;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 12px;gap:8px;box-shadow:0 1px 3px color-mix(in srgb,var(--ant-color-text-base) 7%,transparent)}.hamburger{border:0;background:transparent;font-size:var(--ant-font-size);color:var(--ant-color-text-secondary)}.brand-mark,.login-logo{color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-3);line-height:1;font-weight:600}.topbar>strong{font-size:var(--ant-font-size)}.divider{height:19px;border-left:1px solid var(--ant-color-border);margin:0 4px}.top-actions{margin-left:auto;display:flex;align-items:center;gap:16px;color:var(--ant-color-text-secondary)}.top-actions>span{font-size:var(--ant-font-size);position:relative}.top-actions i{position:absolute;width:5px;height:5px;background:var(--ant-color-error);border-radius:50%;right:-2px;top:-2px}.top-actions small{font-size:var(--ant-font-size-sm);line-height:1.2}.avatar{width:23px;height:23px;border-radius:50%;background:var(--ant-color-primary-bg);color:var(--ant-color-primary);display:grid!important;place-items:center;font-size:var(--ant-font-size-sm)!important}.site-switcher{margin:4px 8px 4px;height:30px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);padding:0 8px;width:calc(100% - 18px)}.site-switcher:focus{outline:1px solid var(--ant-color-primary)}.side-nav{position:fixed;z-index:35;top:56px;bottom:0;left:0;width:190px;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);display:flex;flex-direction:column;padding:0}.side-title{height:48px;padding:16px 16px 0;font-weight:600;font-size:var(--ant-font-size)}.side-title span{float:right;color:var(--ant-color-text-secondary)}.side-nav>button{height:41px;border:0;background:transparent;text-align:left;padding:0 16px;display:flex;align-items:center;gap:12px;border-left:3px solid transparent;font-size:var(--ant-font-size)}.side-nav>button:hover{background:var(--ant-color-fill-quaternary);color:var(--ant-color-primary-hover)}.side-nav>button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);border-left-color:var(--ant-color-primary)}.side-nav>button b{width:18px;text-align:center}.side-nav>button em{font-style:normal;width:21px;height:21px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;place-items:center;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container)}.side-nav>button.active em{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-bg-container)}.side-nav>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);padding:20px 16px 8px;text-transform:uppercase;letter-spacing:.8px}.side-help{position:relative;margin:auto 12px 12px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:12px;display:flex;flex-direction:column;gap:4px;font-size:var(--ant-font-size-sm)}.side-help span{color:var(--ant-color-text-secondary)}
+.model-trigger{display:flex;align-items:center;justify-content:space-between;cursor:pointer;color:var(--ant-color-text-secondary)}
+.model-trigger:hover{color:var(--ant-color-primary-hover)}
+.model-trigger .caret{font-size:var(--ant-font-size-sm);margin-left:4px}
+.model-backdrop{position:fixed;inset:0;z-index:60}
+.model-popover{position:absolute;left:0;right:0;bottom:calc(100% + 6px);z-index:61;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:0 8px 22px color-mix(in srgb,var(--ant-color-text-base) 13%,transparent);padding:8px;display:flex;flex-direction:column;gap:4px}
+.model-popover-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
+.model-popover-head>span{font-weight:600;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}
+.model-popover-head>small{color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-sm)}
+.model-row{display:flex;align-items:center;gap:8px;padding:4px 8px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);font-size:var(--ant-font-size-sm);cursor:pointer}
+.model-row input[type=radio]{margin:0;accent-color:var(--ant-color-primary)}
+.model-row .model-name{font-weight:600;color:var(--ant-color-text-base);flex:1}
+.model-row .model-tag{color:var(--ant-color-success);font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-success-bg);padding:4px 4px;border-radius:var(--ant-border-radius);white-space:nowrap}
+.model-row .model-rate{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);min-width:34px;text-align:right}
+.model-row.disabled{opacity:.45;cursor:not-allowed;background:var(--ant-color-bg-layout)}
+.model-row.disabled .model-tag{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}
+.model-row:not(.disabled):hover{border-color:var(--ant-color-primary-bg);background:var(--ant-color-bg-layout)}.workspace{min-height:calc(100vh - 56px);padding-bottom:64px}.crumb{height:34px;padding:12px 20px 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.crumb b{color:var(--ant-color-primary)}.page-heading{min-height:60px;padding:4px 20px 12px;display:flex;align-items:center;justify-content:space-between}.page-heading h1{font-size:var(--ant-font-size-heading-4);margin:0 0 4px}.page-heading p{margin:0;color:var(--ant-color-text-secondary)}.button{border:1px solid var(--ant-color-primary-active);background:var(--ant-color-primary-active);color:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);min-width:92px;height:31px;padding:0 16px;margin-left:8px}.button:hover{background:var(--ant-color-primary-hover)}.button.secondary{background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);border-color:var(--ant-color-border)}.button.secondary:hover{border-color:var(--ant-color-primary);color:var(--ant-color-primary)}.button:disabled{opacity:.55;cursor:not-allowed}.steps{height:70px;background:var(--ant-color-bg-container);border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);display:grid;grid-template-columns:repeat(4,1fr);padding:0 24px}.steps button{border:0;background:transparent;display:flex;align-items:center;position:relative;text-align:left;color:var(--ant-color-text-secondary);padding:0 20px}.steps button>i{font-style:normal;width:29px;height:29px;border-radius:50%;border:1px solid var(--ant-color-border);display:grid;place-items:center;margin-right:12px;background:var(--ant-color-bg-container)}.steps button span{display:flex;flex-direction:column;gap:4px}.steps button small{font-size:var(--ant-font-size-sm)}.steps button>b{position:absolute;right:0;font-size:var(--ant-font-size-heading-3);color:var(--ant-color-text-disabled)}.steps button.active{color:var(--ant-color-text-base)}.steps button.active>i{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}.steps button.done>i{background:var(--ant-color-success);border-color:var(--ant-color-success);color:var(--ant-color-bg-container)}.steps button.locked{cursor:default}.content{padding:16px 20px}.panel{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);margin-bottom:16px}.panel-head{min-height:43px;border-bottom:1px solid var(--ant-color-border);padding:8px 12px;display:flex;justify-content:space-between;align-items:center}.panel-head>strong{font-size:var(--ant-font-size)}.panel-head .button{height:28px}.panel-body{padding:12px}.summary-strip{display:grid;grid-template-columns:repeat(5,1fr);height:64px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);margin-bottom:16px}.summary-strip div{display:flex;align-items:center;justify-content:center;gap:8px;border-right:1px solid var(--ant-color-border)}.summary-strip div:last-child{border:0}.summary-strip b{font-size:var(--ant-font-size-heading-3)}.summary-strip span{color:var(--ant-color-text-secondary)}.filterbar{height:45px;display:flex;align-items:center;gap:8px;padding-bottom:12px;border-bottom:1px solid var(--ant-color-border)}.filterbar>label{width:205px;height:30px;border:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 8px;color:var(--ant-color-text-secondary)}.filterbar input{border:0;outline:0;width:100%;padding-left:8px}.filterbar select,.preview-toolbar select{height:30px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 24px 0 8px}.filterbar>span{flex:1}.view-switch{display:flex}.view-switch button,.icon-button{width:31px;height:29px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.view-switch button.active{background:var(--ant-color-primary);color:var(--ant-color-bg-container);border-color:var(--ant-color-primary)}.icon-button{border:0;font-size:var(--ant-font-size)}.data-table{width:100%;border-collapse:collapse}.data-table th{height:35px;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);text-align:left;font-size:var(--ant-font-size-sm);font-weight:600;border-bottom:1px solid var(--ant-color-border);padding:0 12px}.data-table td{height:48px;border-bottom:1px solid var(--ant-color-border);padding:8px 12px;font-size:var(--ant-font-size-sm)}.data-table tr:hover td{background:var(--ant-color-bg-layout)}.link{border:0;background:transparent;color:var(--ant-color-primary);padding:0}.site-icon{display:inline-grid;place-items:center;width:30px;height:30px;margin-right:8px;color:var(--ant-color-primary);border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);font-size:var(--ant-font-size);vertical-align:middle}.site-icon.big{width:40px;height:40px;font-size:var(--ant-font-size-heading-3)}.status{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm);line-height:1.2;border:1px solid}.status.ok{color:var(--ant-color-success);background:var(--ant-color-bg-layout);border-color:var(--ant-color-success)}.status.warn{color:var(--ant-color-error);background:var(--ant-color-primary-bg);border-color:var(--ant-color-error-bg)}.status.bad{color:var(--ant-color-error);background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}.status.muted{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}.dots,.trash{border:0;background:transparent;color:var(--ant-color-text-secondary)}.pagination{height:46px;display:flex;justify-content:flex-end;align-items:center;gap:8px;padding-top:12px}.pagination span{margin-right:12px;color:var(--ant-color-text-secondary)}.pagination button{width:28px;height:28px;border:0;background:var(--ant-color-bg-container)}.pagination button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.pagination select{border:0;background:var(--ant-color-bg-container)}.project-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:12px 0}.project-grid article{border:1px solid var(--ant-color-border);padding:16px;cursor:pointer}.project-grid article:hover{border-color:var(--ant-color-primary);box-shadow:none}.project-grid article>div{display:flex;justify-content:space-between}.project-grid h3{margin:12px 0 4px}.project-grid p,.project-grid small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.project-grid section{display:flex;gap:20px;border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);padding:12px 0;margin:12px 0}.project-grid section span{display:flex;flex-direction:column}.project-grid section b{font-size:var(--ant-font-size)}.two-column{display:grid;grid-template-columns:1fr 340px;gap:16px}.two-column.equal{grid-template-columns:1fr 1fr}.basic-layout>.panel>.panel-body{padding:16px}.stack{display:flex;flex-direction:column}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.form-grid>label{display:flex;flex-direction:column;gap:8px;font-size:var(--ant-font-size-sm);font-weight:600}.form-grid>label>input,.form-grid>label>select,.form-grid>label>textarea{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:8px;outline:none;font-weight:400}.form-grid>label>input:focus,.form-grid>label>select:focus,.form-grid>label>textarea:focus{border-color:var(--ant-color-primary)}.form-grid>label>input,.form-grid>label>select{height:34px}.form-grid>label>textarea{min-height:80px;resize:vertical;line-height:1.6}.field{display:flex;flex-direction:column;gap:8px}.field>span{font-size:var(--ant-font-size-sm);font-weight:600}.field>span i{color:red;font-style:normal;margin-right:4px}.field input,.field select,.field textarea{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:8px;outline:none}.field input,.field select{height:34px}.field textarea{min-height:130px;resize:vertical;line-height:1.6}.field textarea.small{min-height:80px}.field small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.form-grid .field:nth-child(n+3){grid-column:1/-1}.upload{height:90px;border:1px dashed var(--ant-color-border);background:var(--ant-color-bg-layout);display:flex;flex-direction:column;justify-content:center;align-items:center;color:var(--ant-color-text-secondary);gap:4px}.upload b{color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-4)}.ai-box{text-align:center;padding:12px}.ai-box>span{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid);display:grid;place-items:center;margin:auto;font-weight:600}.ai-box h3{font-size:var(--ant-font-size);margin:12px}.ai-box p{color:var(--ant-color-text-secondary);line-height:1.7;text-align:left}.meter{height:7px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);overflow:hidden}.meter i{display:block;height:100%;background:linear-gradient(90deg,var(--ant-color-primary),var(--ant-color-success));transition:width .35s}.tips{padding-left:24px;line-height:2;color:var(--ant-color-text-secondary)}.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.kpi-row>div{height:75px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:12px 16px;position:relative}.kpi-row span{display:block;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm);margin-bottom:8px}.kpi-row b{font-size:var(--ant-font-size-heading-3)}.kpi-row small{margin-left:4px;color:var(--ant-color-text-secondary)}.kpi-row .status{margin-top:8px}.editable td{padding:8px}.editable input,.editable select{height:29px;border:1px solid var(--ant-color-border);width:100%;padding:0 8px;background:var(--ant-color-bg-container)}.editable .number{width:65px}.tree{line-height:2.2}.tree .l1{padding-left:24px}.tree .l2{padding-left:52px;color:var(--ant-color-text-secondary)}.tree span{border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);padding:4px 8px;font-size:var(--ant-font-size-sm)}.file-card{display:flex;align-items:center;gap:12px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);padding:12px}.file-card .excel{width:36px;height:42px;background:var(--ant-color-success);color:var(--ant-color-bg-container);display:grid;place-items:center;font-weight:600}.file-card>div{flex:1}.file-card p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.checks{display:flex;gap:20px;color:var(--ant-color-success);padding:12px 4px 0;font-size:var(--ant-font-size-sm)}.tabs{height:46px;border-bottom:1px solid var(--ant-color-border);display:flex;gap:8px;margin:-12px -12px 0;padding-left:12px}.tabs button{padding:0 20px;border:0;border-bottom:2px solid transparent;background:var(--ant-color-bg-container)}.tabs button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.tabs i{font-style:normal;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);padding:4px 8px;margin-left:4px}.section-toolbar{display:flex;justify-content:space-between;align-items:center;padding:16px 4px}.section-toolbar p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.mu-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.mu-grid article{border:1px solid var(--ant-color-border);display:grid;grid-template-columns:42% 58%;min-height:128px;cursor:pointer}.mu-grid article.selected{border:2px solid var(--ant-color-primary);background:var(--ant-color-bg-layout)}.mu-thumb{margin:8px;background:var(--ant-color-bg-layout);display:grid;place-items:center;position:relative;overflow:hidden}.mu-thumb:after{content:"";width:72%;height:40%;border:1px solid var(--ant-color-primary-bg);background:var(--ant-color-bg-container);box-shadow:none}.mu-thumb b{position:absolute;z-index:2;color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-3)}.mu-grid article>div:last-child{padding:12px 12px 8px 0}.mu-grid header{display:flex;gap:8px;align-items:center}.mu-grid h3{font-size:var(--ant-font-size-sm);margin:0}.mu-grid p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.5;min-height:32px}.mu-grid button{border:0;background:transparent;color:var(--ant-color-primary);padding:0}.blue{color:var(--ant-color-primary)}.type-tag{background:var(--ant-color-bg-layout);padding:4px 8px;border-radius:var(--ant-border-radius)}.wide-cell{min-width:300px}.menu-builder{display:grid;grid-template-columns:230px 1fr 240px;gap:16px;background:var(--ant-color-bg-layout);padding:16px}.page-pool,.menu-tree,.menu-preview{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:12px}.page-pool p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.page-pool div{border:1px solid var(--ant-color-border);padding:12px;margin-top:8px;background:var(--ant-color-bg-layout)}.menu-tree section{border:1px solid var(--ant-color-border);margin-bottom:8px;padding:12px}.menu-tree section>span{float:right}.menu-tree section>div{margin:8px 0 0 16px;padding:8px;border-top:1px solid var(--ant-color-border)}.menu-tree i{font-style:normal;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);color:var(--ant-color-primary);padding:4px;margin-right:8px}.menu-tree i.cube{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}.menu-tree>button{border:1px dashed var(--ant-color-border);background:var(--ant-color-bg-layout);width:100%;height:34px}.menu-preview aside{background:var(--ant-color-bg-layout);min-height:250px;display:flex;flex-direction:column;padding-top:12px}.menu-preview strong{padding:12px}.menu-preview button{border:0;background:transparent;text-align:left;padding:12px}.menu-preview button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.generate-layout{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.confirm-grid div{border:1px solid var(--ant-color-border);padding:12px;display:flex;flex-direction:column;gap:4px;position:relative}.confirm-grid span,.confirm-grid small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.confirm-grid button{position:absolute;right:10px;top:10px;border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.option-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}.option-list label{display:flex;align-items:center;gap:8px}.task-panel{height:100%}.overall{background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-primary);padding:12px}.overall>div:first-child{display:flex;justify-content:space-between;margin-bottom:8px}.overall p{margin:8px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.task-list{padding:12px 0}.task-list>div{display:flex;align-items:center;border-bottom:1px solid var(--ant-color-border);padding:8px 4px}.task-list>div>i{font-style:normal;width:25px;height:25px;border-radius:50%;border:1px solid var(--ant-color-border);display:grid;place-items:center;margin-right:8px}.task-list>div>i.done{background:var(--ant-color-success);border-color:var(--ant-color-success);color:var(--ant-color-bg-container)}.task-list>div>i.spin{color:var(--ant-color-primary)}.task-list>div>span{display:flex;flex:1;flex-direction:column;gap:4px}.task-list small{color:var(--ant-color-text-secondary)}.task-panel>.panel-body>.button{width:100%;margin:4px 0}.result-header{display:flex;align-items:center;justify-content:space-between;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:12px;margin-bottom:16px}.result-header>div{display:flex;align-items:center;gap:12px}.result-header h2{margin:0 0 4px}.result-header p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.result-layout{display:grid;grid-template-columns:1fr 280px;gap:16px}.result-pages{display:grid;grid-template-columns:1fr;gap:12px}.result-pages article{border:1px solid var(--ant-color-border)}.result-pages article>div:last-child{padding:12px}.result-pages article header{display:flex;justify-content:space-between}.result-pages h3{font-size:var(--ant-font-size-sm);margin:12px 0 4px}.result-pages p,.result-pages small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.result-pages footer{display:flex;margin-top:8px}.result-pages footer .button{margin:0 4px 0 0;min-width:68px;height:27px}.dashboard-thumb{height:115px;background:var(--ant-color-bg-layout);position:relative;padding:12px 12px 12px 36px}.dashboard-thumb>i{position:absolute;left:0;top:0;bottom:0;width:25px;background:var(--ant-color-fill-secondary)}.dashboard-thumb>header{display:block;height:10px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}.dashboard-thumb>section{display:flex;gap:4px;margin-top:4px}.dashboard-thumb>section b{height:22px;flex:1;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-primary)}.dashboard-thumb>footer{position:absolute;left:35px;right:46px;bottom:10px;height:50px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:flex;align-items:flex-end;gap:4px;padding:8px}.dashboard-thumb>footer em{flex:1;background:var(--ant-color-primary);height:45%}.dashboard-thumb>footer em:nth-child(2){height:75%}.dashboard-thumb>footer em:nth-child(4){height:92%}.dashboard-thumb>span{position:absolute;right:9px;bottom:10px;width:31px;height:50px;background:conic-gradient(var(--ant-color-success) 0 72%,var(--ant-color-fill-secondary) 72%)}.dashboard-thumb-live{position:relative;min-height:115px;background:var(--ant-color-bg-layout);border-bottom:1px solid var(--ant-color-border);overflow:hidden}.dashboard-thumb-live>div{position:relative}.thumb-empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}result-menu div{display:flex;justify-content:space-between;border-bottom:1px solid var(--ant-color-border);padding:12px 4px}.result-menu span{background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius);padding:4px 8px}.text-button{border:0;background:transparent;color:var(--ant-color-primary);margin-top:12px}.score{display:flex;align-items:center;justify-content:center;gap:12px;padding:12px}.score b{width:55px;height:55px;border-radius:50%;border:5px solid var(--ant-color-success);display:grid;place-items:center;font-size:var(--ant-font-size)}.score span{color:var(--ant-color-success)}.validation{padding-left:12px;list-style:none;line-height:2;color:var(--ant-color-success)}.preview-shell{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);margin:-4px}.preview-toolbar{height:45px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--ant-color-border)}.preview-toolbar>div{display:flex;align-items:center;gap:8px}.preview-toolbar button{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);height:29px;padding:0 12px}.preview-main{display:grid;grid-template-columns:1fr 300px;height:655px}.cube-canvas{position:relative;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);overflow:hidden}.cube-top{position:absolute;left:0;right:0;top:0;height:32px;background:var(--ant-color-bg-container);border-top:3px solid var(--ant-color-primary-active);border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 12px;gap:16px}.cube-top b{color:var(--ant-color-primary)}.cube-top i{margin-left:auto;font-style:normal;color:var(--ant-color-text-secondary)}.cube-side{position:absolute;left:0;top:32px;bottom:0;width:130px;background:var(--ant-color-bg-layout);display:flex;flex-direction:column}.cube-side strong{padding:16px 12px}.cube-side button{height:37px;border:0;background:transparent;text-align:left;padding-left:12px}.cube-side button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.cube-content{position:absolute;left:130px;top:32px;right:0;bottom:0;padding:12px}.cube-content>header{display:flex;justify-content:space-between;align-items:center}.cube-content>header h2{font-size:var(--ant-font-size);margin:4px 0 8px}.cube-content>header span,.cube-content>header small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.cube-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.cube-kpis>div{height:78px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px;position:relative;display:flex;flex-direction:column}.cube-kpis span{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.cube-kpis b{font-size:var(--ant-font-size-heading-4);margin:4px 0}.cube-kpis small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.spark{position:absolute;right:9px;bottom:14px;width:42px;height:18px;border-top:3px solid var(--ant-color-primary);transform:skewY(-12deg);border-radius:50%}.spark.s1{transform:skewY(8deg)}.spark.s2{border-color:var(--ant-color-error)}.cube-grid{display:grid;grid-template-columns:1.7fr .8fr;gap:8px;margin-top:8px}.cube-grid>section{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:12px}.cube-grid h3{font-size:var(--ant-font-size-sm);margin:0 0 8px}.cube-grid h3 span,.cube-grid h3 button{float:right;border:0;background:transparent;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.line-chart{height:175px;position:relative}.line-chart>div{position:absolute;inset:0 0 20px;background:repeating-linear-gradient(to bottom,var(--ant-color-bg-container) 0,var(--ant-color-bg-container) 29px,var(--ant-color-fill-secondary) 30px)}.line-chart svg{position:absolute;left:0;right:0;top:0;height:145px;width:100%}.line-chart footer{position:absolute;left:0;right:0;bottom:0;display:flex;justify-content:space-between;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.donut{width:115px;height:115px;border-radius:50%;background:conic-gradient(var(--ant-color-success) 0 80%,var(--ant-color-error-bg) 80% 92%,var(--ant-color-text-disabled) 92%);margin:12px auto;display:grid;place-items:center}.donut:before{content:"";position:absolute;width:72px;height:72px;background:var(--ant-color-bg-container);border-radius:50%}.donut b{z-index:1;font-size:var(--ant-font-size-heading-4);display:flex;flex-direction:column;align-items:center}.donut small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.donut-panel footer{display:flex;justify-content:space-around;font-size:var(--ant-font-size-sm)}.donut-panel footer i{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px}.green{background:var(--ant-color-success)}.orange{background:var(--ant-color-error-bg)}.gray{background:var(--ant-color-text-disabled)}.alarm-panel{grid-column:1/-1}.alarm-panel table{border-collapse:collapse;width:100%;font-size:var(--ant-font-size-sm)}.alarm-panel td{border-top:1px solid var(--ant-color-border);padding:8px}.inspector{background:var(--ant-color-bg-container);overflow:auto}.inspector>nav{height:42px;display:flex;border-bottom:1px solid var(--ant-color-border)}.inspector>nav button{flex:1;border:0;border-bottom:2px solid transparent;background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}.inspector>nav button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.inspector-body{padding:16px}.inspector-body h3{font-size:var(--ant-font-size)}.inspector-body p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.7}.inspector-body dl{display:grid;grid-template-columns:85px 1fr;border-top:1px solid var(--ant-color-border);margin:16px 0}.inspector-body dt,.inspector-body dd{margin:0;padding:8px 0;border-bottom:1px solid var(--ant-color-border);font-size:var(--ant-font-size-sm)}.inspector-body dt{color:var(--ant-color-text-secondary)}.component{padding:8px;border:1px solid var(--ant-color-border);margin-top:8px}.component span{float:right}.code-body header{text-align:right}.code-body header button{border:0;background:transparent;color:var(--ant-color-primary)}.code-body pre{font-size:var(--ant-font-size-sm);line-height:1.55;background:var(--ant-color-text-base);color:var(--ant-color-primary-bg);padding:12px;min-height:410px;overflow:auto}.inspector-body textarea{width:100%;height:120px;border:1px solid var(--ant-color-border);padding:8px;resize:none}.suggestion{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius);padding:4px 8px;margin:0 4px 8px 0;font-size:var(--ant-font-size-sm)}.inspector-body>.button{width:100%;margin:12px 0}.sticky-actions{position:fixed;z-index:20;left:232px;right:0;bottom:0;height:54px;background:var(--ant-color-bg-container);border-top:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:space-between;padding:0 20px;box-shadow:none}.toast{position:fixed;z-index:100;right:24px;top:56px;background:var(--ant-color-text-base);color:var(--ant-color-bg-container);padding:12px 20px;box-shadow:none}.toast b{color:var(--ant-color-success);margin-right:8px}.modal-backdrop{position:fixed;z-index:90;inset:0;background:color-mix(in srgb,var(--ant-color-bg-mask) 47%,transparent);display:grid;place-items:center}.modal{width:610px;max-height:78vh;background:var(--ant-color-bg-container);box-shadow:0 10px 30px color-mix(in srgb,var(--ant-color-text-base) 27%,transparent);display:flex;flex-direction:column}.modal>header{height:47px;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:space-between;padding:0 16px}.modal h2{font-size:var(--ant-font-size)}.modal>header button{border:0;background:transparent;font-size:var(--ant-font-size-heading-3);color:var(--ant-color-text-secondary)}.modal-body{padding:16px;overflow:auto;flex:1 1 auto;min-height:0}.modal>footer{border-top:1px solid var(--ant-color-border);padding:12px 16px;text-align:right}.modal-note{background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);padding:12px;margin-bottom:16px;color:var(--ant-color-primary)}.impact-title{font-weight:600;margin-bottom:8px}.impact-list{list-style:none;padding:0;border:1px solid var(--ant-color-border)}.impact-list li{display:flex;justify-content:space-between;padding:12px;border-bottom:1px solid var(--ant-color-border)}.impact-list span{color:var(--ant-color-primary)}.download-list{display:flex;flex-direction:column}.download-list label{padding:12px;border-bottom:1px solid var(--ant-color-border);display:flex;gap:8px}.download-list>div{margin-top:16px;background:var(--ant-color-bg-layout);padding:12px;display:flex;justify-content:space-between}.history-list>div{display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ant-color-border);padding:12px}.history-list i{font-style:normal;width:34px;height:34px;border-radius:50%;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center}.history-list span{display:flex;flex:1;flex-direction:column;gap:4px}.history-list small{color:var(--ant-color-text-secondary)}.history-list button{border:0;background:transparent;color:var(--ant-color-primary)}.login-page{min-height:100vh;background:linear-gradient(145deg,var(--ant-color-bg-layout),var(--ant-color-bg-container) 55%,var(--ant-color-bg-layout));border-top:4px solid var(--ant-color-primary-active);display:flex;align-items:center;justify-content:center}.login-top{position:absolute;left:18px;top:14px;display:flex;align-items:center;gap:12px}.login-top b{font-size:var(--ant-font-size)}.login-top span{border-left:1px solid var(--ant-color-border);padding-left:12px;color:var(--ant-color-text-secondary)}.login-card{width:420px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);box-shadow:none;padding:44px;text-align:center}.login-logo{font-size:var(--ant-font-size-heading-2)}.login-card h1{font-size:var(--ant-font-size-heading-3)}.login-card p{color:var(--ant-color-text-secondary);line-height:1.6}.sso-button{width:100%;height:48px;margin:24px 0 12px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;gap:12px;padding:0 12px}.sso-button b{flex:1;text-align:left}.sso-button i{font-style:normal;font-size:var(--ant-font-size);color:var(--ant-color-primary)}.login-card>small{color:var(--ant-color-text-secondary)}.login-page>footer{position:absolute;bottom:18px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.avatar-button{border:0;padding:0}
+.design-link{margin-left:auto;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm);text-decoration:none;padding:0 12px;border-right:1px solid var(--ant-color-border)}
+.design-link:hover{text-decoration:underline}.avatar-button:hover{outline:1px solid var(--ant-color-primary)}
+.steps{grid-template-columns:repeat(5,1fr)}
+.domain-steps{grid-template-columns:repeat(7,1fr)}
+.module-switch{padding:12px 8px 8px;border-bottom:1px solid var(--ant-color-border);display:grid;gap:8px}.module-switch>button{height:47px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;text-align:left;padding:0 8px;gap:8px;font-size:var(--ant-font-size)}.module-switch>button>b{width:23px;height:23px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary)}.module-switch>button>span{display:flex;flex-direction:column;font-weight:600;gap:4px}.module-switch>button small{font-weight:400;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.module-switch>button.active{border-color:transparent;background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.module-switch>button:hover{background:var(--ant-color-fill-quaternary)}.module-switch>button.active>b{background:var(--ant-color-primary);color:var(--ant-color-bg-container)}.side-nav>.side-title{display:none}
+.domain-hero{min-height:160px;background:linear-gradient(120deg,var(--ant-color-primary),var(--ant-color-primary) 58%,var(--ant-color-primary));color:var(--ant-color-bg-container);margin-bottom:16px;padding:28px 32px;display:flex;align-items:center;justify-content:space-between;position:relative;overflow:hidden}.domain-hero:after{content:"◆";position:absolute;right:34%;top:-55px;font-size:var(--ant-font-size-heading-2);color:color-mix(in srgb,var(--ant-color-text-light-solid) 4%,transparent);transform:rotate(16deg)}.domain-hero>div:first-child{max-width:640px;z-index:1}.domain-hero .eyebrow{font-size:var(--ant-font-size-sm);letter-spacing:2px;opacity:.8}.domain-hero h2{font-size:var(--ant-font-size-heading-3);margin:8px 0}.domain-hero p{margin:0;line-height:1.7;opacity:.86}.domain-hero-stats{display:grid;grid-template-columns:1fr 1fr;gap:4px;background:color-mix(in srgb,var(--ant-color-bg-container) 22%,transparent);z-index:1}.domain-hero-stats div{width:105px;height:58px;background:color-mix(in srgb,var(--ant-color-bg-container) 8%,transparent);display:flex;flex-direction:column;align-items:center;justify-content:center}.domain-hero-stats b{font-size:var(--ant-font-size-heading-4)}.domain-hero-stats span{font-size:var(--ant-font-size-sm);opacity:.8}.domain-filter label{width:300px}.domain-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:16px 0 4px}.domain-grid article{border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-border);padding:16px;min-height:230px;background:var(--ant-color-bg-container)}.domain-grid article.water{border-top-color:var(--ant-color-primary)}.domain-grid article.gas{border-top-color:var(--ant-color-error)}.domain-grid article.energy{border-top-color:var(--ant-color-error-bg)}.domain-grid article.dc{border-top-color:var(--ant-color-primary)}.domain-grid article>header{display:flex;justify-content:space-between;align-items:center}.domain-grid article>header>span{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-primary)}.domain-grid article>header>div{display:flex;align-items:center;gap:8px}.domain-grid h3{font-size:var(--ant-font-size);margin:12px 0 8px}.domain-grid p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.65;min-height:34px}.domain-metrics{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);padding:8px 0;gap:8px}.domain-metrics span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.domain-metrics b{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base);margin-right:4px}.domain-grid footer{display:flex;justify-content:space-between;margin-top:12px}.domain-grid footer button{border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.domain-grid footer button:last-child{border:1px solid var(--ant-color-primary-bg);padding:4px 8px}.solution-parts{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.solution-parts>div{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);padding:12px;display:flex;gap:8px;min-height:80px}.solution-parts i{font-style:normal;font-size:var(--ant-font-size-heading-4);color:var(--ant-color-primary)}.solution-parts span{display:flex;flex-direction:column;gap:8px}.solution-parts small{font-size:var(--ant-font-size-sm);line-height:1.5;color:var(--ant-color-text-secondary)}
+.solution-banner{min-height:126px;border:1px solid var(--ant-color-border);background:linear-gradient(100deg,var(--ant-color-bg-layout),var(--ant-color-bg-container));display:flex;align-items:center;padding:20px;margin-bottom:12px}.solution-logo{width:72px;height:72px;background:var(--ant-color-primary);color:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);display:grid;place-items:center;font-size:var(--ant-font-size-heading-2);margin-right:20px}.solution-banner>div:nth-child(2){flex:1}.solution-banner h2{margin:4px 0;font-size:var(--ant-font-size-heading-4)}.solution-banner p{margin:0 0 8px;color:var(--ant-color-text-secondary)}.solution-banner small{margin-left:12px;color:var(--ant-color-text-secondary)}.solution-banner aside{display:flex}.solution-summary{display:grid;grid-template-columns:repeat(6,1fr);background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);margin-bottom:12px}.solution-summary>div{height:65px;border-right:1px solid var(--ant-color-border);display:flex;flex-direction:column;align-items:center;justify-content:center}.solution-summary b{font-size:var(--ant-font-size-heading-4)}.solution-summary span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin-top:4px}.detail-grid{display:grid;grid-template-columns:1fr 310px;gap:12px}.asset-template{display:grid;grid-template-columns:1fr 1.3fr;gap:16px}.asset-tree{background:var(--ant-color-bg-layout);padding:12px;display:flex;flex-direction:column;gap:12px}.asset-tree span{padding-left:16px}.asset-tree em{font-style:normal;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.template-specs{display:grid;gap:8px}.template-specs>div{border:1px solid var(--ant-color-border);padding:8px}.template-specs p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.45}.domain-page-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}.domain-page-list article{display:grid;grid-template-columns:115px 1fr;border:1px solid var(--ant-color-border);min-height:145px}.domain-page-thumb{margin:8px;background:var(--ant-color-bg-layout);position:relative;padding:28px 8px 8px;display:grid;grid-template-columns:repeat(3,1fr);gap:4px}.domain-page-thumb span{position:absolute;left:7px;top:6px;color:var(--ant-color-primary)}.domain-page-thumb i{background:var(--ant-color-primary);height:40px}.domain-page-thumb i:nth-child(2){height:62px}.domain-page-thumb i:nth-child(3){height:50px}.domain-page-list section{padding:12px 8px 8px 0}.domain-page-list section>div{display:flex;justify-content:space-between}.domain-page-list h3{font-size:var(--ant-font-size-sm);margin:8px 0 4px}.domain-page-list section>b{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.domain-page-list p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.5}.domain-page-list footer button{border:0;background:transparent;color:var(--ant-color-primary);padding:0;margin-right:12px;font-size:var(--ant-font-size-sm)}.flow-list{display:grid;gap:8px}.flow-list>div{display:flex;align-items:center;border:1px solid var(--ant-color-border);padding:8px}.flow-list i{font-style:normal;width:29px;height:29px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;margin-right:8px}.flow-list span{display:flex;flex:1;flex-direction:column;gap:4px}.flow-list small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.flow-list em{font-style:normal;font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.flow-list .future-item{border-style:dashed;opacity:.75}.solution-menu{display:flex;flex-direction:column}.solution-menu>b{padding:8px;background:var(--ant-color-bg-layout);margin-top:4px}.solution-menu>span{padding:8px 8px 8px 16px;border-bottom:1px solid var(--ant-color-border)}.solution-menu i{font-style:normal;font-size:var(--ant-font-size-sm);color:var(--ant-color-primary);margin-right:8px}.knowledge-list>div{display:flex;align-items:center;border-bottom:1px solid var(--ant-color-border);padding:8px 4px}.knowledge-list>div>i{font-style:normal;width:27px;color:var(--ant-color-primary)}.knowledge-list span{display:flex;flex:1;flex-direction:column;gap:4px}.knowledge-list small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.knowledge-list button{border:0;background:transparent;color:var(--ant-color-primary)}
+.ai-config-layout{display:grid;grid-template-columns:1fr 370px;gap:16px}.wordbuddy-card{background:var(--ant-color-text-base);color:var(--ant-color-fill-secondary);border-radius:var(--ant-border-radius);overflow:hidden;box-shadow:none}.wordbuddy-card>header{height:58px;border-bottom:1px solid var(--ant-color-border);display:flex;justify-content:space-between;align-items:center;padding:0 16px}.wordbuddy-card>header>div{display:flex;gap:8px;align-items:center}.wb-logo{width:31px;height:31px;border-radius:var(--ant-border-radius);background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));display:grid;place-items:center;font-weight:600}.wordbuddy-card header section{display:flex;flex-direction:column}.wordbuddy-card header small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin-top:4px}.wordbuddy-card header label{display:flex;align-items:center;gap:8px}.model-list{padding:8px}.model-list>button{width:100%;min-height:56px;border:0;border-radius:var(--ant-border-radius);background:transparent;color:var(--ant-color-fill-secondary);display:flex;align-items:center;padding:8px 8px;text-align:left;position:relative}.model-list>button:hover,.model-list>button.selected{background:var(--ant-color-text-base)}.model-list>button>i{font-style:normal;width:25px;color:var(--ant-color-text-disabled);font-weight:600}.model-list>button>span{display:flex;flex:1;flex-direction:column;gap:4px}.model-list>button>span>b{font-size:var(--ant-font-size-sm)}.model-list>button small{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.model-list>button em{font-style:normal;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.model-list>button>strong{color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-sm);margin-right:24px}.model-list>button>u{position:absolute;right:9px;color:var(--ant-color-success);text-decoration:none;font-size:var(--ant-font-size)}.wordbuddy-card>footer{border-top:1px solid var(--ant-color-border);padding:8px}.wordbuddy-card>footer button{border:0;background:transparent;color:var(--ant-color-text-disabled)}.selected-model{display:flex;align-items:center;gap:12px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-primary);padding:12px}.selected-model>span{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-bg-container);display:grid;place-items:center}.selected-model section{flex:1}.selected-model h3{margin:4px 0;font-size:var(--ant-font-size)}.selected-model p{margin:0;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.model-facts{display:grid;grid-template-columns:110px 1fr;margin:12px 0 0}.model-facts dt,.model-facts dd{margin:0;padding:8px;border-bottom:1px solid var(--ant-color-border);font-size:var(--ant-font-size-sm)}.model-facts dt{color:var(--ant-color-text-secondary)}.strategy-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}.strategy-list label{border:1px solid var(--ant-color-border);padding:8px;display:flex;gap:8px}.strategy-list label>span{display:flex;flex-direction:column;gap:4px}.strategy-list small,.setting-row small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.setting-row{display:flex;align-items:center;border-top:1px solid var(--ant-color-border);padding:8px 4px}.setting-row>span{display:flex;flex:1;flex-direction:column;gap:4px}.context-list{display:grid;gap:8px}.context-list>span{display:flex;align-items:center;background:var(--ant-color-bg-layout);padding:8px}.context-list i{font-style:normal;width:25px;color:var(--ant-color-primary)}.context-list b{flex:1;font-size:var(--ant-font-size-sm)}.context-list em{font-style:normal;color:var(--ant-color-success);font-size:var(--ant-font-size-sm)}.token-estimate{display:flex;flex-direction:column;border-top:1px solid var(--ant-color-border);margin-top:12px;padding-top:12px;gap:4px}.token-estimate span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
+@media(max-width:1100px){.app-shell{padding-left:44px}.side-nav{display:none}.workspace{min-width:900px}.sticky-actions{left:42px}.top-actions small{display:none}.result-layout{grid-template-columns:1fr}.preview-main{grid-template-columns:1fr 260px}.mu-grid{grid-template-columns:repeat(2,1fr)}}
+
+/* 右下角浮动 AI 会话 */
+.ai-fab{position:fixed;right:22px;bottom:22px;z-index:80;width:54px;height:54px;border-radius:50%;border:0;background:transparent;box-shadow:0 8px 22px color-mix(in srgb,var(--ant-color-primary) 33%,transparent);display:grid;place-items:center;cursor:pointer;touch-action:none;user-select:none}.ai-fab-icon{width:54px;height:54px;border-radius:50%;object-fit:cover;-webkit-user-drag:none;user-drag:none;user-select:none;pointer-events:none}
+.design-doc-link{display:block;margin-top:auto;margin-left:12px;margin-right:12px;margin-bottom:12px;padding:4px 8px;font-size:var(--ant-font-size-sm);line-height:1.4;font-weight:600;text-align:center;color:var(--ant-color-text-light-solid);text-decoration:none;border-radius:var(--ant-border-radius);background:linear-gradient(135deg,var(--ant-color-primary) 0%,var(--ant-color-primary-bg) 100%);box-shadow:none;opacity:.95;transition:opacity .15s}
+.design-doc-link:hover{opacity:1;color:var(--ant-color-text-light-solid)}
+.ai-fab:active{cursor:grabbing}
+.ai-fab:hover:not(:active){transform:translateY(-1px);box-shadow:0 12px 26px color-mix(in srgb,var(--ant-color-primary-hover) 40%,transparent)}
+.ai-dialog{position:fixed;right:22px;bottom:88px;z-index:81;width:25vw;min-width:320px;height:70vh;min-height:420px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:0 18px 48px color-mix(in srgb,var(--ant-color-text-base) 20%,transparent);display:flex;flex-direction:column;overflow:hidden}
+.ai-dialog header{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--ant-color-border);background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid)}
+.ai-dialog .ai-dialog-logo{width:28px;height:28px;border-radius:var(--ant-border-radius);background:color-mix(in srgb,var(--ant-color-bg-container) 20%,transparent);display:grid;place-items:center;font-size:var(--ant-font-size)}
+.ai-dialog .ai-dialog-title{flex:1;display:flex;flex-direction:column;line-height:1.25}
+.ai-dialog .ai-dialog-title b{font-size:var(--ant-font-size-sm)}
+.ai-dialog .ai-dialog-title small{font-size:var(--ant-font-size-sm);opacity:.85}
+.ai-dialog .ai-close{border:0;background:transparent;color:var(--ant-color-text-light-solid);font-size:var(--ant-font-size-heading-4);line-height:1;width:28px;height:28px;border-radius:var(--ant-border-radius)}
+.ai-dialog .ai-close:hover{background:color-mix(in srgb,var(--ant-color-bg-container) 20%,transparent)}
+.ai-messages{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:12px;background:var(--ant-color-bg-layout)}
+.ai-welcome{width:100%;margin:auto 0;display:flex;flex-direction:column;gap:12px;color:var(--ant-color-text-base)}.ai-welcome-intro{display:flex;align-items:center;gap:12px;padding-bottom:4px}.ai-welcome-intro>span{width:32px;height:32px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;flex:0 0 auto}.ai-welcome-intro>div{min-width:0}.ai-welcome-intro b{display:block;font-size:var(--ant-font-size);line-height:1.4}.ai-welcome-intro p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.5}.ai-capability-list{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.ai-capability-list>div{min-height:52px;padding:8px 12px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ant-color-border)}.ai-capability-list>div:last-child{border-bottom:0}.ai-capability-list i{width:22px;height:22px;border-radius:50%;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;flex:0 0 auto;font-size:var(--ant-font-size-sm);font-style:normal;font-weight:600}.ai-capability-list span{display:flex;flex-direction:column;gap:4px;min-width:0}.ai-capability-list b{font-size:var(--ant-font-size-sm);line-height:1.35;font-weight:600}.ai-capability-list small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.35}.ai-welcome-tip{padding:8px 12px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.5}.ai-welcome-tip b{color:var(--ant-color-text-base)}
+.ai-bubble{display:flex;gap:8px;align-items:flex-start;max-width:88%}
+.ai-bubble .ai-avatar{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;font-size:var(--ant-font-size-sm);background:var(--ant-color-fill-secondary);color:var(--ant-color-primary)}
+.ai-bubble.me{flex-direction:row-reverse;align-self:flex-end}
+.ai-bubble.me .ai-avatar{background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid)}
+.ai-bubble .ai-text{white-space:pre-wrap;word-break:break-word;font-size:var(--ant-font-size-sm);line-height:1.6;padding:8px 12px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}
+.ai-bubble.me .ai-text{background:var(--ant-color-primary);color:var(--ant-color-text-light-solid);border-color:var(--ant-color-primary)}
+.ai-dialog footer{display:flex;gap:8px;padding:8px;border-top:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}
+.ai-dialog .ai-input{flex:1;resize:none;height:42px;max-height:120px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 12px;font-size:var(--ant-font-size-sm);line-height:1.4;outline:none}
+.ai-dialog .ai-input:focus{border-color:var(--ant-color-primary-bg)}
+.ai-dialog .ai-send{border:0;background:var(--ant-color-primary);color:var(--ant-color-text-light-solid);border-radius:var(--ant-border-radius);padding:0 16px;font-size:var(--ant-font-size-sm);align-self:flex-end;height:42px}
+.ai-dialog .ai-send:disabled{opacity:.5;cursor:not-allowed}
+.ai-dialog .ai-send:not(:disabled):hover{background:var(--ant-color-primary-hover)}
+
+/* ===== 新建站点向导:基本属性 ===== */
+.domain-select{display:flex;align-items:center;gap:8px}
+.domain-select input{flex:1;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container)}
+.domain-select input:focus{border-color:var(--ant-color-primary);outline:none}
+.domain-add{display:flex;align-items:center;gap:8px;margin-top:8px}
+.domain-add input{flex:1;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container)}
+.domain-add input:focus{border-color:var(--ant-color-primary);outline:none}
+.file-list{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:8px}
+.file-list li{display:flex;align-items:center;gap:8px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 8px;font-size:var(--ant-font-size-sm)}
+.file-list a{color:var(--ant-color-primary);text-decoration:none}
+.file-list a:hover{text-decoration:underline}
+.file-list small{color:var(--ant-color-text-secondary);margin-left:auto}
+.invalid{border-color:var(--ant-color-error)!important;background:var(--ant-color-bg-layout)!important}
+.hint{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);margin:0 0 12px;line-height:1.6}
+
+/* ===== 可编辑表格内的输入控件 ===== */
+.data-table input,.data-table select,.data-table textarea{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:28px;padding:0 8px;width:100%;background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}
+.data-table input:focus,.data-table select:focus,.data-table textarea:focus{border-color:var(--ant-color-primary);outline:none}
+.data-table .number{width:100%;text-align:right}
+.data-table input::placeholder{color:var(--ant-color-text-disabled)}
+/* 站点列表:列宽可手动拖动调整(拖拽表头右缘的分隔条) */
+.data-table.resizable-cols{table-layout:fixed;width:100%}
+.data-table.resizable-cols th{position:relative}
+.data-table .col-resizer{position:absolute;top:0;right:0;width:6px;height:100%;cursor:col-resize;z-index:2;user-select:none}
+.data-table .col-resizer::after{content:"";position:absolute;top:0;right:2px;width:2px;height:100%;background:transparent}
+.data-table .col-resizer:hover::after,.data-table .col-resizer.active::after{background:var(--ant-color-primary)}
+/* 可编辑表格使用固定列宽,避免输入框在自动布局下被压窄(设备类型框变窄的 BUG) */
+.data-table.editable{table-layout:fixed}
+.data-table.editable th:nth-child(1){width:40%}
+.data-table.editable th:nth-child(2){width:92px}
+.data-table.editable th:nth-child(3){width:56px}
+.data-table.editable th:nth-child(4){width:auto}
+.data-table.editable th:nth-child(5){width:48px}
+.data-table.editable input,.data-table.editable select,.data-table.editable textarea{min-width:0;width:100%}
+.data-table.editable .trash{border:0;background:transparent;color:var(--ant-color-error);font-size:var(--ant-font-size);cursor:pointer;line-height:1}
+.data-table.editable .trash:hover{color:var(--ant-color-error)}
+.data-table.editable .asset-eye-btn{border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size);cursor:pointer;line-height:1;padding:0;display:inline-flex;align-items:center}
+.data-table.editable .asset-eye-btn.disabled{color:var(--ant-color-text-disabled);cursor:default}
+
+/* ===== 领域方案库:列表筛选栏 + 可排序列头 ===== */
+.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px}
+.filter-bar .filter-search{flex:1 1 220px;min-width:180px;height:30px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container)}
+.filter-bar .filter-search:focus{border-color:var(--ant-color-primary);outline:none}
+.filter-bar select{height:30px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 8px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary)}
+.filter-bar select:focus{border-color:var(--ant-color-primary);outline:none}
+.filter-bar .filter-count{margin-left:auto;font-size:var(--ant-font-size-sm)}
+.data-table th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
+.data-table th.sortable:hover{color:var(--ant-color-primary)}
+.data-table th.sortable .sort-ind{color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}
+
+/* ===== Assets 资产配置 ===== */
+.empty-row td{background:var(--ant-color-bg-layout)}
+.empty-row input{color:var(--ant-color-text-disabled);background:var(--ant-color-bg-layout)}
+.empty-row input::placeholder{color:var(--ant-color-text-disabled)}
+.asset-tree-preview{background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:12px 16px;font-size:var(--ant-font-size-sm)}
+.tree-root{font-weight:600;color:var(--ant-color-text-secondary);margin-bottom:8px;display:flex;align-items:center;gap:4px}
+.tree-branch{margin:4px 0 4px 16px;padding-left:12px;border-left:1px dashed var(--ant-color-border)}
+.tree-node{display:flex;align-items:center;gap:8px;font-weight:600;color:var(--ant-color-text-secondary)}
+.site-structure-title{margin-top:20px}
+.site-structure{grid-column:2;margin-top:8px}
+.site-structure .tree-branch{margin:4px 0 4px 0;padding-left:12px;border-left:1px solid var(--ant-color-border)}
+.site-structure .tree-node{font-weight:400;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}
+.site-structure .tree-node.current-site{color:var(--ant-color-primary);background:var(--ant-color-primary-bg);border-radius:var(--ant-border-radius);padding:4px 4px;margin-left:-4px}
+.tree-count{background:var(--ant-color-primary-bg);color:var(--ant-color-primary-active);border-radius:var(--ant-border-radius);padding:0 8px;font-size:var(--ant-font-size-sm);font-weight:400}
+.tree-leaves{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.7;margin:4px 0 0 20px;word-break:break-all}
+
+/* ===== Functions 功能与菜单 ===== */
+.mu-tree{display:flex;flex-direction:column;gap:16px}
+.mu-group>b{display:block;color:var(--ant-color-primary-active);font-size:var(--ant-font-size-sm);margin-bottom:8px}
+.mu-items{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px 20px;border-left:1px dashed var(--ant-color-border);margin-left:8px}
+.mu-items label{display:flex;align-items:flex-start;gap:8px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 8px;background:var(--ant-color-bg-container);cursor:pointer;font-size:var(--ant-font-size-sm)}
+.mu-items label:hover{border-color:var(--ant-color-primary-bg);background:var(--ant-color-bg-layout)}
+.mu-items label.disabled{opacity:.5;cursor:not-allowed;background:var(--ant-color-bg-layout)}
+.mu-items label input{margin-top:4px;accent-color:var(--ant-color-primary)}
+.mu-items label span{display:flex;flex-direction:column;line-height:1.3}
+.mu-items label small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.two-column>div:first-child{min-width:0}
+.cube-editor{overflow-x:auto}
+.cube-editor table{width:100%}
+.cube-editor input{width:100%}
+.cube-editor .cube-editor-table th:nth-child(1){width:120px}
+.cube-editor .cube-editor-table th:nth-child(2){width:auto}
+.cube-editor .cube-editor-table th:nth-child(3){width:140px}
+.cube-editor .cube-editor-table th:nth-child(4){width:200px}
+.cube-editor .cube-editor-table td:nth-child(3){white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.cube-actions{display:flex;gap:4px;white-space:nowrap}
+.cube-actions button{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);height:26px;padding:0 8px;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
+.cube-actions button:hover{border-color:var(--ant-color-primary);color:var(--ant-color-primary)}
+.cube-actions .trash{border-color:var(--ant-color-error-bg);color:var(--ant-color-error)}
+.cube-actions .trash:hover{background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}
+/* 醒目 AI 操作按钮(渐变蓝紫) */
+.btn-ai{display:inline-flex;align-items:center;gap:4px;background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid);border:1px solid transparent;border-radius:var(--ant-border-radius);height:26px;padding:0 12px;font-size:var(--ant-font-size-sm);font-weight:600;cursor:pointer;box-shadow:none;white-space:nowrap}
+.btn-ai:hover{filter:brightness(1.08)}
+.btn-ai:active{transform:translateY(1px)}
+.btn-ai:disabled{opacity:.65;cursor:default;box-shadow:none}
+.cube-actions .btn-ai{height:26px}
+.menu-editor{display:flex;flex-direction:column;gap:12px}
+.menu-row{display:grid;grid-template-columns:1.4fr 1fr 1.4fr auto;gap:8px;align-items:center}
+.menu-row input,.menu-row select{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container);width:100%}
+.menu-row .trash{border:1px solid var(--ant-color-error-bg);background:var(--ant-color-bg-container);color:var(--ant-color-error);border-radius:var(--ant-border-radius);height:30px;width:34px;font-size:var(--ant-font-size)}
+.menu-row .trash:hover{background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}
+.menu-preview .menu-tree{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}
+.menu-preview .menu-root{padding:12px 12px;font-weight:600;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}
+.menu-preview .menu-section{padding:12px 12px;border-bottom:1px solid var(--ant-color-border)}
+.menu-preview .menu-section:last-child{border-bottom:0}
+.menu-preview .menu-section-title{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary-active);font-weight:600;margin-bottom:8px}
+.menu-preview .menu-leaf{padding:4px 0 4px 16px;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);border-left:2px solid var(--ant-color-border);margin:4px 0 4px 4px}
+.menu-preview .menu-leaf.cube{border-left-color:var(--ant-color-primary)}
+.menu-preview .menu-leaf.muted{color:var(--ant-color-text-disabled);border-left-color:var(--ant-color-border)}
+
+/* ===== Generate 预览 line 面板 ===== */
+.line-panel{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:12px 12px;display:flex;flex-direction:column;gap:8px}
+.line-panel h3{margin:0;font-size:var(--ant-font-size-sm);display:flex;align-items:center;justify-content:space-between}
+.line-panel h3 span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);font-weight:400}
+
+/* ===== 生成提示词查看 ===== */
+.prompt-list{display:flex;flex-direction:column;gap:8px}
+.prompt-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 12px;background:var(--ant-color-bg-container)}
+.prompt-row span{display:flex;flex-direction:column;line-height:1.35;min-width:0}
+.prompt-row span small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}
+.prompt-row b{font-size:var(--ant-font-size-sm)}
+.prompt-view{margin:0;background:#0f1115;color:#e6edf3;border:1px solid #2a2f36;border-radius:6px;padding:14px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:60vh;overflow:auto}
+
+/* ===== 站点列表:多选与删除 ===== */
+.data-table input[type=checkbox]{display:block;width:16px;height:16px;padding:0;margin:0 auto;appearance:none;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:none;background:var(--ant-color-bg-container);cursor:pointer;vertical-align:middle}
+.data-table input[type=checkbox]:checked{border-color:var(--ant-color-primary);background-color:var(--ant-color-primary);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.6 11 12.5 4.8' fill='none' stroke='var(--ant-color-bg-container)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:center;background-repeat:no-repeat;background-size:14px 14px}
+.data-table input[type=checkbox]:focus-visible{outline:2px solid var(--ant-color-primary-bg);outline-offset:1px}
+.data-table thead th:first-child{width:34px;text-align:center}
+.link.danger{color:var(--ant-color-error)}
+.link.danger:hover{text-decoration:underline}
+.project-grid article{position:relative}
+.card-del{position:absolute;top:8px;right:8px;border:1px solid var(--ant-color-error-bg);background:var(--ant-color-bg-container);color:var(--ant-color-error);border-radius:var(--ant-border-radius);height:26px;padding:0 8px;font-size:var(--ant-font-size-sm);cursor:pointer}
+.card-del:hover{background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}
+.style-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;padding:16px 0 4px}
+.style-card{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;flex-direction:column}
+.style-card .style-thumb{position:relative;aspect-ratio:16/9;min-height:200px;background:var(--ant-color-bg-layout);display:grid;place-items:center;cursor:zoom-in;overflow:hidden}
+.style-card .style-thumb img{width:100%;height:100%;object-fit:contain}
+.style-card .style-thumb .zoom{position:absolute;right:8px;bottom:8px;background:color-mix(in srgb,var(--ant-color-bg-mask) 53%,transparent);color:var(--ant-color-text-light-solid);width:24px;height:24px;border-radius:var(--ant-border-radius);display:grid;place-items:center;font-size:var(--ant-font-size-sm)}
+.style-info{padding:12px 12px 4px}
+.style-info h3{font-size:var(--ant-font-size);margin:0}
+.style-info small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.style-card footer{display:flex;justify-content:flex-end;gap:16px;padding:8px 12px}
+.style-lightbox{max-width:92vw;max-height:88vh}
+.style-upload-row{display:flex;gap:12px;align-items:stretch}
+.style-upload-prev{width:150px;height:120px;border:1px solid var(--ant-color-border);display:grid;place-items:center;background:var(--ant-color-bg-layout);overflow:hidden}
+.style-upload-prev img{width:100%;height:100%;object-fit:contain}
+.style-json-row{display:flex;gap:12px;align-items:flex-start}
+.style-json-row textarea{flex:1;min-height:150px;resize:vertical;font-family:var(--ant-font-family);font-size:var(--ant-font-size-sm);line-height:1.5}
+.style-json-row .upload{width:120px;height:64px;flex:0 0 auto;margin-top:4px}
+.modal>footer .spacer{flex:1}
+.style-picker{margin-top:20px;border-top:1px dashed var(--ant-color-border);padding-top:16px}
+.style-picker h4{margin:0 0 4px;font-size:var(--ant-font-size)}
+.style-picker h4 small{color:var(--ant-color-text-secondary);font-weight:400;font-size:var(--ant-font-size-sm);margin-left:8px}
+.style-pick-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;margin-top:8px}
+.style-pick{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:8px;display:flex;flex-direction:column;gap:8px;cursor:pointer;text-align:center;color:var(--ant-color-text-secondary)}
+.style-pick:hover{border-color:var(--ant-color-primary)}
+.style-pick.active{border:2px solid var(--ant-color-primary);background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
+.style-pick-thumb{height:78px;background:var(--ant-color-bg-layout);display:grid;place-items:center;overflow:hidden}
+.style-pick-thumb img{width:100%;height:100%;object-fit:contain}
+.style-pick-thumb.none{color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-sm)}
+.style-pick span{font-size:var(--ant-font-size-sm);line-height:1.3}
+
+/* ========================================================================== */
+/* Company workspace standard — measured from the WISE-IoT service desk UI.   */
+/* This final layer intentionally normalizes legacy prototype styles while the */
+/* remaining feature pages are migrated to shared company components.         */
+/* ========================================================================== */
+:root{
+  
+  
+  
+  
+  
+  
+}
+html,body{font-family:var(--ant-font-family);font-size:var(--ant-font-size);line-height:1.5;color:var(--ant-color-text-base);background:var(--ant-color-bg-container)}
+.app-shell{min-height:100vh;padding-top:56px;padding-left:200px;background:var(--ant-color-bg-container)}
+.topbar{height:56px;border-top:4px solid var(--ant-color-primary);border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);background-image:none;box-shadow:none;padding:0 20px;gap:12px}
+.topbar>strong{font-size:var(--ant-font-size-heading-3);color:var(--ant-color-primary);letter-spacing:-.3px}.topbar .brand-mark{font-size:var(--ant-font-size-heading-4);color:var(--ant-color-primary)}
+.top-actions{gap:16px}.top-actions a{color:var(--ant-color-primary);text-decoration:none}.top-actions small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
+.hamburger{font-size:var(--ant-font-size);color:var(--ant-color-text-secondary)}.divider{height:22px}
+.side-nav{top:56px;width:200px;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border)}
+.module-switch{padding:0;border-bottom:1px solid var(--ant-color-border);display:flex;flex-direction:column;gap:0}
+.module-switch>button{height:50px;border:0;border-radius:0;background:transparent;padding:0 20px;gap:12px;font-size:var(--ant-font-size);font-weight:600}
+.module-switch>button>b{width:18px;height:auto;background:transparent;color:var(--ant-color-text-secondary)}
+.module-switch>button>span{display:block;font-weight:600}.module-switch>button small{display:none}
+.module-switch>button:hover{background:var(--ant-color-bg-layout)}.module-switch>button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}
+.module-switch>button.active>b{background:transparent;color:var(--ant-color-primary)}
+.side-nav>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);padding:20px 20px 8px;letter-spacing:0;text-transform:none}
+.side-nav>button{height:44px;padding:0 20px;border-left:0;font-size:var(--ant-font-size);gap:12px}
+.side-nav>button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);border-left:0;font-weight:600}
+.side-nav>button em{width:20px;height:20px;font-size:var(--ant-font-size-sm)}
+.site-switcher{height:32px;width:calc(100% - 32px);margin:8px 16px;border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);font-size:var(--ant-font-size-sm)}
+.side-help{margin:auto 16px 16px;border:0;border-top:1px solid var(--ant-color-border);background:transparent;padding:16px 4px 0}
+.workspace{min-height:calc(100vh - 56px);padding-bottom:72px;background:var(--ant-color-bg-container)}.crumb{display:none}
+.page-heading{min-height:64px;padding:12px 24px;background:var(--ant-color-bg-layout);border-bottom:1px solid var(--ant-color-border)}
+.page-heading h1{font-size:var(--ant-font-size-heading-3);line-height:36px;font-weight:600;margin:0;color:var(--ant-color-text-base)}
+.page-heading p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin:0}.page-heading>div:last-child{display:flex;gap:8px}
+.content{padding:0 24px 24px;background:var(--ant-color-bg-container)}
+.button.ant-btn{min-width:112px;height:32px;margin:0;border-radius:var(--ant-border-radius);padding:0 24px;font-size:var(--ant-font-size);font-weight:600;box-shadow:none}
+.button.ant-btn-primary{background:var(--ant-color-primary);border-color:var(--ant-color-primary)}.button.ant-btn-primary:hover{background:var(--ant-color-primary-hover)!important;border-color:var(--ant-color-primary-hover)!important}
+.button.ant-btn-default,.button.secondary{background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);border-color:var(--ant-color-border)}.button.ant-btn-default:hover,.button.secondary:hover{color:var(--ant-color-primary)!important;border-color:var(--ant-color-primary)!important;background:var(--ant-color-bg-container)!important}
+.steps{height:64px;border-top:0;border-bottom:1px solid var(--ant-color-border);padding:0 24px;background:var(--ant-color-bg-container)}.steps button{padding:0 20px;font-size:var(--ant-font-size)}.steps button>i{width:26px;height:26px}.steps button.active>i{background:var(--ant-color-primary);border-color:var(--ant-color-primary)}
+.panel{border:1px solid var(--ant-color-border);border-radius:0;background:var(--ant-color-bg-container);margin:16px 0;box-shadow:none}.panel-head{min-height:48px;padding:0 16px;background:var(--ant-color-bg-container)}.panel-head>strong{font-size:var(--ant-font-size)}.panel-body{padding:16px}
+.company-status-tabs{height:56px;display:flex;align-items:flex-end;border-bottom:1px solid var(--ant-color-border);gap:0;padding-top:8px}
+.company-status-tabs > button{height:48px;min-width:120px;padding:0 24px;border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}
+.company-status-tabs > button span{margin-right:8px}.company-status-tabs > button b{display:inline-grid;place-items:center;min-width:24px;height:20px;padding:0 8px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.company-status-tabs > button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary);font-weight:600}.company-status-tabs > button.active b{background:var(--ant-color-fill-secondary);color:var(--ant-color-success)}
+.company-list-panel{margin-top:16px}.company-list-panel>.panel-body{padding:0 16px 12px}
+.company-filterbar{height:auto;min-height:56px;padding:12px 0;gap:8px}.company-filterbar .search-field{width:240px;height:32px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;background:var(--ant-color-bg-container)}.company-filterbar .search-field:focus-within{border-color:var(--ant-color-primary)}
+.company-filterbar select{height:32px;min-width:160px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:0 28px 0 12px}
+.filter-actions{margin-left:auto;display:flex;gap:8px}.filter-actions .button{min-width:96px}
+.company-table{table-layout:fixed}.company-table th{height:44px;background:var(--ant-color-bg-layout);font-size:var(--ant-font-size);font-weight:600;padding:0 12px}.company-table td{height:50px;padding:8px 12px;font-size:var(--ant-font-size);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.company-table tbody tr{cursor:pointer}.company-table tbody tr:hover td{background:var(--ant-color-bg-layout)}.company-table th:first-child,.company-table td:first-child{width:44px;overflow:visible;text-overflow:clip}.company-table th:last-child,.company-table td:last-child{width:70px;text-align:center}
+.company-record-link{font-size:var(--ant-font-size)}.company-record-link b{color:var(--ant-color-primary)}
+.status.ant-tag{height:24px;line-height:21px;border-radius:var(--ant-border-radius);padding:0 12px;margin:0;font-size:var(--ant-font-size-sm)}.status.ok{color:var(--ant-color-success);background:var(--ant-color-success-bg);border-color:var(--ant-color-success)}.status.warn{color:var(--ant-color-warning);background:var(--ant-color-warning-bg);border-color:var(--ant-color-warning)}.status.bad{color:var(--ant-color-error);background:var(--ant-color-error-bg);border-color:var(--ant-color-error)}.status.muted{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}
+.pagination{height:56px;padding:12px 0 0;gap:0}.pagination span{border:1px solid var(--ant-color-border);height:32px;line-height:30px;padding:0 12px;margin-right:8px}.pagination button,.pagination select{height:32px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.pagination button{width:34px}.pagination button.active{background:var(--ant-color-bg-container);color:var(--ant-color-text-base)}.pagination select{margin-left:8px;padding:0 12px}
+.company-detail .detail-status{margin-bottom:20px}.company-detail h3{font-size:var(--ant-font-size);margin:20px 0 8px}.company-detail dl{display:grid;grid-template-columns:110px 1fr;margin:0}.company-detail dt,.company-detail dd{margin:0;padding:12px 0;border-bottom:1px solid var(--ant-color-border)}.company-detail dt{color:var(--ant-color-text-secondary)}
+.ant-drawer .ant-drawer-header{min-height:64px;border-bottom:1px solid var(--ant-color-border)}.ant-drawer .ant-drawer-title{font-size:var(--ant-font-size-heading-4);font-weight:600}.ant-drawer .ant-drawer-body{padding:20px 24px}
+.company-form-page{display:grid;grid-template-columns:220px minmax(0,860px);gap:32px;align-items:start;padding-top:20px}
+.form-section-nav{position:sticky;top:76px;display:grid;grid-template-columns:32px 1fr;padding:4px 0;color:var(--ant-color-text-base)}.form-section-nav>i.form-section-nav-dot{width:8px;height:8px;border-radius:50%;background:var(--ant-color-primary);justify-self:center;align-self:center}.form-section-nav>span{font-size:var(--ant-font-size);font-weight:600;line-height:24px;margin-bottom:12px}.form-section-nav>a{grid-column:2;color:var(--ant-color-text-secondary);text-decoration:none;padding:8px 0 8px 12px;border-left:1px solid var(--ant-color-border)}.form-section-nav>a:hover{color:var(--ant-color-primary)}
+.single-column-form{width:100%}.single-column-form .panel{margin:0}.single-column-form .panel-body{padding:20px 24px 32px}.single-column-form .form-grid{display:grid;grid-template-columns:1fr!important;gap:20px}.single-column-form .form-grid>*{grid-column:1!important}.single-column-form .field{max-width:680px;gap:8px}.single-column-form .field>span{font-size:var(--ant-font-size)}.single-column-form .field input,.single-column-form .field select{height:40px;border-radius:var(--ant-border-radius);padding:0 12px}.single-column-form .field textarea{min-height:112px;border-radius:var(--ant-border-radius);padding:12px 12px}.single-column-form .field textarea.small{min-height:88px}.single-column-form .field small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
+.domain-select{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center}.domain-select .text-button{margin:0}.domain-add{max-width:520px}.domain-add .button{min-width:88px}.field-assist{display:flex;align-items:flex-end;justify-content:flex-start;gap:12px;margin-top:4px}.field-assist .button{flex:none;min-width:140px}.form-section-heading{max-width:680px;border-top:1px solid var(--ant-color-border);padding-top:20px;margin-top:4px}.form-section-heading h3{font-size:var(--ant-font-size);margin:0 0 4px}.form-section-heading p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.form-guidance{max-width:680px;padding:12px 16px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);display:flex;flex-direction:column;gap:4px}.form-guidance span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.save-indicator{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.upload{height:96px;border-color:var(--ant-color-border);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius)}.upload b{font-size:var(--ant-font-size-heading-4)}.upload span{font-size:var(--ant-font-size)}.upload small{font-size:var(--ant-font-size-sm)}
+.ai-assist-intro{display:flex;gap:16px;padding:16px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border)}.ai-assist-intro>.anticon{font-size:var(--ant-font-size-heading-3);color:var(--ant-color-primary)}.ai-assist-intro h3{font-size:var(--ant-font-size);margin:0 0 8px}.ai-assist-intro p{margin:0;color:var(--ant-color-text-secondary);line-height:1.7}.ai-assist-drawer .field>span{font-size:var(--ant-font-size);font-weight:600}.drawer-actions{display:flex;gap:8px;margin:20px 0}.drawer-actions .button{min-width:120px}
+.result-header{min-height:72px;border:0;border-bottom:1px solid var(--ant-color-border);padding:12px 0;margin:0}.result-header h2{font-size:var(--ant-font-size-heading-4)}.result-header .site-icon{border-radius:var(--ant-border-radius);background:var(--ant-color-primary);color:var(--ant-color-text-light-solid);border:0;font-size:var(--ant-font-size)}.result-actions{display:flex;gap:8px}.result-actions .button{min-width:112px}
+.kpi-row{border-bottom:1px solid var(--ant-color-border);gap:0;margin:0 0 16px}.kpi-row>div{height:72px;border:0;border-right:1px solid var(--ant-color-border);padding:12px 16px}.kpi-row>div:last-child{border-right:0}.kpi-row span{color:var(--ant-color-text-secondary)}.kpi-row b{font-size:var(--ant-font-size-heading-3)}
+.result-layout{grid-template-columns:1fr}.result-pages{display:grid;grid-template-columns:1fr;gap:0}.result-pages article{display:grid;grid-template-columns:300px 1fr;min-height:180px;border:0;border-bottom:1px solid var(--ant-color-border);padding:16px 0}.result-pages article>div:last-child{padding:0 20px}.result-pages h3{font-size:var(--ant-font-size);margin:16px 0 8px}.result-pages p{font-size:var(--ant-font-size-sm);line-height:1.6}.result-pages footer{gap:8px}.result-pages footer .button{min-width:96px;height:32px;margin:0}.dashboard-thumb-live{min-height:160px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}
+.text-button{color:var(--ant-color-primary);font-size:var(--ant-font-size)}.modal,.login-card{border-radius:var(--ant-border-radius)}.modal{box-shadow:0 6px 24px color-mix(in srgb,var(--ant-color-text-base) 13%,transparent)}.toast{top:68px;border-radius:var(--ant-border-radius);background:var(--ant-color-text-base)}
+.ai-fab{box-shadow:0 4px 12px color-mix(in srgb,var(--ant-color-text-base) 20%,transparent)}.ai-dialog{border-radius:var(--ant-border-radius)}.ai-dialog header{background:var(--ant-color-primary)}
+@media(max-width:1200px){.company-form-page{grid-template-columns:180px minmax(0,1fr);gap:20px}.single-column-form .field,.form-section-heading,.form-guidance{max-width:100%}}
+@media(max-width:900px){.app-shell{padding-left:0}.side-nav{display:none}.workspace{min-width:0}.company-form-page{grid-template-columns:1fr}.form-section-nav{display:none}.content{padding:0 16px 20px}.page-heading{padding:12px 16px}.company-status-tabs{overflow-x:auto}.company-status-tabs > button{min-width:100px}.company-filterbar{align-items:stretch;flex-wrap:wrap}.filter-actions{margin-left:0;width:100%}.result-pages article{grid-template-columns:1fr}.sticky-actions{left:0}}
+
+/* Company navigation IA: global menu contains business modules only. */
+.side-nav{display:none!important}
+.app-shell{padding-left:200px}
+.company-side-nav{position:fixed;z-index:35;top:56px;bottom:0;left:0;width:200px;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);display:flex;flex-direction:column;color:var(--ant-color-text-base);font-family:var(--ant-font-family)}
+.side-menu{padding:0;overflow:auto;flex:1}.menu-section{margin:0}.menu-section button{width:100%;border:0;background:transparent;color:inherit;text-align:left;font-family:var(--ant-font-family);font-weight:600}
+.menu-section-title{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;font-size:var(--ant-font-size-heading-4);line-height:56px}
+.menu-section-title>span{display:block}.menu-section-title>.anticon{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base)}
+.menu-section-title:hover{background:var(--ant-color-fill-secondary)}.menu-section.current>.menu-section-title{color:var(--ant-color-text-base);font-weight:600}
+.menu-section-items button{height:50px;padding:0 20px 0 40px;display:flex;align-items:center;font-size:var(--ant-font-size);line-height:50px;color:var(--ant-color-text-base)}
+.menu-section-items button:hover{background:var(--ant-color-fill-secondary);color:var(--ant-color-primary)}
+.menu-section-items button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
+.extension-menu-items{padding-bottom:4px}.extension-menu-item{height:50px;padding:0 20px 0 40px;display:flex;align-items:center;font-size:var(--ant-font-size);line-height:50px;color:var(--ant-color-text-base);font-weight:600;cursor:default}.extension-submenu-title{justify-content:space-between;cursor:pointer}.extension-submenu-title>.anticon{font-size:var(--ant-font-size-sm);margin-right:4px}.extension-third-menu>div{height:50px;padding:0 20px 0 60px;display:flex;align-items:center;color:var(--ant-color-text-base);font-size:var(--ant-font-size);line-height:50px;font-weight:600;cursor:default}.extension-menu-section .extension-menu-item:hover,.extension-menu-section .extension-third-menu>div:hover{background:transparent;color:inherit}
+.extension-third-menu>button{height:50px;padding:0 20px 0 60px;display:flex;align-items:center;color:var(--ant-color-text-base);font-size:var(--ant-font-size);line-height:50px;font-weight:600;cursor:pointer;border:0;background:transparent;width:100%;text-align:left;font-family:var(--ant-font-family)}
+.extension-menu-section .extension-third-menu>button.clickable:hover{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
+.extension-menu-section .extension-third-menu>button.clickable.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
+.company-side-footer{position:relative;margin:auto 16px 16px;padding:16px 4px 0;border-top:1px solid var(--ant-color-border);display:flex;flex-direction:column;gap:8px;font-size:var(--ant-font-size-sm)}
+.company-side-footer>b{color:var(--ant-color-primary)}.company-side-footer>button{height:26px;padding:0;border:0;background:transparent;display:flex;align-items:center;justify-content:space-between;color:var(--ant-color-text-secondary);text-align:left}
+.company-model-menu{position:absolute;left:0;right:0;bottom:30px;padding:8px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);box-shadow:none}
+.company-model-menu button{width:100%;height:32px;border:0;background:var(--ant-color-bg-container);display:flex;justify-content:space-between;align-items:center;padding:0 8px}.company-model-menu button:hover{background:var(--ant-color-bg-layout)}.company-model-menu span{color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}
+.sticky-actions{left:200px}
+@media(max-width:720px){.company-side-nav{display:none}.app-shell{padding-left:0}.sticky-actions{left:0}}
+
+.site-edit-switcher{display:flex;align-items:center;gap:12px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}.site-edit-switcher select{width:220px;height:34px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:0 32px 0 12px;color:var(--ant-color-text-base)}.site-edit-switcher select:focus{outline:1px solid var(--ant-color-primary);border-color:var(--ant-color-primary)}
+.site-edit-tabs{height:64px;background:var(--ant-color-bg-container);border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);display:flex;padding:0 24px;gap:0}.site-edit-tabs>button{height:64px;flex:1;min-width:0;border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);display:flex;align-items:center;gap:12px;padding:0 20px;font-size:var(--ant-font-size);text-align:left}.site-edit-tabs>button i{font-style:normal;width:26px;height:26px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;place-items:center;background:var(--ant-color-bg-container)}.site-edit-tabs>button:hover{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.site-edit-tabs>button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary);font-weight:600}.site-edit-tabs>button.active i{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}.site-step-blank{min-height:calc(100vh - 286px);background:var(--ant-color-bg-container)}
+.asset-config-shell{min-width:0}.asset-config-tabs{height:66px;display:flex;align-items:flex-end;border:1px solid var(--ant-color-border);border-bottom:0;background:var(--ant-color-bg-container);padding:0 20px;gap:36px}.asset-config-tabs>button{height:65px;min-width:210px;padding:12px 4px 8px;border:0;border-bottom:3px solid transparent;background:transparent;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:4px;color:var(--ant-color-text-secondary)}.asset-config-tabs>button span{font-size:var(--ant-font-size);font-weight:600}.asset-config-tabs>button small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-config-tabs>button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.asset-config-tabs>button.active small{color:var(--ant-color-text-secondary)}.quick-asset-config{border-top:1px solid var(--ant-color-border);padding-top:16px}.manual-asset-list{border-top:1px solid var(--ant-color-border);padding-top:16px}.manual-asset-list>.panel{margin:0}.manual-asset-filter{padding:0 0 16px;margin-bottom:0}.asset-category{border-top:1px solid var(--ant-color-border);margin:0 -12px}.asset-category>header{height:46px;display:flex;align-items:center;gap:8px;padding:0 16px;background:var(--ant-color-bg-layout)}.asset-category>header strong{font-size:var(--ant-font-size)}.asset-category>header span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.asset-category .company-table{margin:0}.asset-category .company-table th:first-child,.asset-category .company-table td:first-child{padding-left:40px}.asset-group-dialog{width:540px}.dialog-form{display:flex;flex-direction:column;gap:20px;margin:20px 0}.dialog-form input,.dialog-form select{height:38px;border:1px solid var(--ant-color-border);padding:0 12px;background:var(--ant-color-bg-container)}
+.manual-asset-editor{border-top:1px solid var(--ant-color-border);padding-top:0;background:var(--ant-color-bg-container)}.asset-editor-title{height:64px;border:1px solid var(--ant-color-border);border-top:0;display:flex;align-items:center;padding:0 16px;gap:20px}.asset-editor-title>.text-button{margin:0;padding-right:20px;border-right:1px solid var(--ant-color-border)}.asset-editor-title>div{flex:1;display:flex;align-items:baseline;gap:12px}.asset-editor-title h2{font-size:var(--ant-font-size);margin:0}.asset-editor-title>div>span{color:var(--ant-color-text-secondary)}.asset-editor-title>.button{margin:0}.asset-editor-workbench{min-height:620px;border:1px solid var(--ant-color-border);border-top:0;display:grid;grid-template-columns:250px 1fr}.asset-editor-side{border-right:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}.asset-editor-side>.field{padding:16px;border-bottom:1px solid var(--ant-color-border)}.asset-editor-side>.field input{background:var(--ant-color-bg-container)}.asset-batch-entry{width:100%;height:54px;border:0;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);text-align:left;padding:0 16px;font-size:var(--ant-font-size)}.asset-batch-entry:hover{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.asset-template-panel{padding:16px;display:flex;flex-direction:column;gap:12px}.asset-template-panel>strong{font-size:var(--ant-font-size)}.asset-template-panel>select{height:34px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 8px}.asset-template-panel>button{min-height:58px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:grid;grid-template-columns:24px 1fr;grid-template-rows:1fr 1fr;text-align:left;padding:8px;column-gap:8px}.asset-template-panel>button>span{grid-row:1/3;color:var(--ant-color-primary);align-self:center}.asset-template-panel>button>b{font-size:var(--ant-font-size-sm)}.asset-template-panel>button>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-template-panel>button:hover{border-color:var(--ant-color-primary)}.asset-tree-workspace{min-width:0;background:var(--ant-color-bg-layout)}.asset-editor-toolbar{height:52px;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 12px;display:flex;align-items:center;justify-content:space-between}.asset-editor-toolbar>div{display:flex;align-items:center;gap:8px}.asset-editor-toolbar .button{margin:0;min-width:auto}.asset-tree-search{width:210px;height:30px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;padding:0 8px;color:var(--ant-color-text-secondary)}.asset-tree-search input{border:0;outline:0;width:100%;padding-left:8px}.asset-tree-canvas{min-height:567px;padding:28px;overflow:auto}.asset-tree-root-card{width:220px;min-height:54px;margin:0 auto 24px;border:1px solid var(--ant-color-border);border-left:4px solid var(--ant-color-primary);background:var(--ant-color-bg-container);box-shadow:none;padding:8px 12px;display:flex;flex-direction:column;gap:4px;position:relative}.asset-tree-root-card:after{content:"";position:absolute;left:50%;bottom:-25px;height:24px;border-left:1px solid var(--ant-color-primary-bg)}.asset-tree-root-card small{color:var(--ant-color-text-secondary)}.asset-node-tree{display:flex;justify-content:center;align-items:flex-start;gap:20px}.asset-tree-branch{position:relative;display:flex;flex-direction:column;align-items:center}.asset-node-card{width:190px;min-height:50px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);box-shadow:none;display:grid;grid-template-columns:24px 1fr 22px;grid-template-rows:1fr 1fr;text-align:left;padding:8px 8px;column-gap:4px;position:relative}.asset-node-card>span{grid-row:1/3;color:var(--ant-color-primary);align-self:center}.asset-node-card>b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.asset-node-card>small{color:var(--ant-color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.asset-node-card>em{grid-column:3;grid-row:1/3;align-self:center;justify-self:end;font-style:normal;width:20px;height:20px;border-radius:50%;background:var(--ant-color-bg-layout);display:grid;place-items:center;color:var(--ant-color-text-secondary)}.asset-node-card:hover,.asset-node-card.selected{border-color:var(--ant-color-primary);box-shadow:none}.asset-tree-children{margin-top:28px;padding-top:0;display:flex;align-items:flex-start;justify-content:center;gap:16px;position:relative}.asset-tree-children:before{content:"";position:absolute;left:50%;top:-28px;height:28px;border-left:1px solid var(--ant-color-primary-bg)}.asset-tree-children>.asset-tree-branch:before{content:"";position:absolute;left:50%;top:-14px;height:14px;border-left:1px solid var(--ant-color-primary-bg)}.asset-tree-children>.asset-tree-branch:not(:only-child):after{content:"";position:absolute;top:-14px;left:-7px;right:-7px;border-top:1px solid var(--ant-color-primary-bg)}.asset-tree-empty{width:420px;margin:60px auto;text-align:center;display:flex;flex-direction:column;gap:8px;color:var(--ant-color-text-secondary)}.asset-tree-empty b{color:var(--ant-color-text-secondary)}.node-drawer-tabs{height:45px;border-bottom:1px solid var(--ant-color-border);display:flex;margin:-8px 0 20px}.node-drawer-tabs button{border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);padding:0 20px;font-size:var(--ant-font-size)}.node-drawer-tabs button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.node-drawer-form{display:flex;flex-direction:column;gap:20px}.node-drawer-form input,.node-drawer-form select,.node-drawer-form textarea{width:100%;border:1px solid var(--ant-color-border);padding:8px;background:var(--ant-color-bg-container)}.node-drawer-form input,.node-drawer-form select{height:38px}.node-drawer-form textarea{min-height:110px}.node-config-list>header,.node-config-list>div{display:grid;grid-template-columns:1fr 1fr 90px;gap:8px;margin-bottom:8px}.node-config-list.binding>header,.node-config-list.binding>div{grid-template-columns:1fr 1fr}.node-config-list>header{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.node-config-list input{height:36px;border:1px solid var(--ant-color-border);padding:0 8px}.node-config-list>.button{margin:8px 0 0}.batch-create-dialog{width:min(760px,calc(100vw - 40px))}.batch-create-dialog .node-drawer-tabs{margin:12px 0 20px}.batch-table>header,.batch-table>div{display:grid;grid-template-columns:1.4fr 90px 1.4fr 32px;gap:8px;margin-bottom:8px}.batch-table>header{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.batch-table input{height:36px;border:1px solid var(--ant-color-border);padding:0 8px}.batch-table>.button{margin:8px 0 12px}.manual-excel-upload{height:180px;border:1px dashed var(--ant-color-border);background:var(--ant-color-bg-layout);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;cursor:pointer}.manual-excel-upload>b{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-primary)}.manual-excel-upload>span{font-size:var(--ant-font-size)}.manual-excel-upload>small{color:var(--ant-color-text-secondary)}
+@media(max-width:1100px){.asset-editor-workbench{grid-template-columns:220px 1fr}.asset-node-card{width:160px}.asset-tree-children{flex-direction:column}.asset-tree-children>.asset-tree-branch:after{display:none}.asset-config-tabs{gap:20px}.asset-config-tabs>button{min-width:180px}}
+
+/* Resource center list pages */
+.resource-list-panel>.panel-body{padding:0 16px 12px;overflow-x:auto}
+.resource-table{min-width:980px;table-layout:auto}.resource-table th,.resource-table td{vertical-align:middle}.resource-table th:last-child,.resource-table td:last-child{width:64px;text-align:center}
+.resource-table th:nth-child(3),.resource-table td:nth-child(3){width:76px}.resource-table th:nth-child(4),.resource-table td:nth-child(4),.resource-table th:nth-child(5),.resource-table td:nth-child(5),.resource-table th:nth-child(6),.resource-table td:nth-child(6){width:70px;text-align:center}
+.resource-table th:nth-child(7),.resource-table td:nth-child(7){width:96px}.resource-table th:nth-child(8),.resource-table td:nth-child(8){width:180px}
+.domain-resource-table th:first-child,.domain-resource-table td:first-child{width:260px;min-width:260px;text-align:left}.domain-resource-table .company-record-link{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
+.resource-detail>p{margin:8px 0;color:var(--ant-color-text-secondary);line-height:1.8;white-space:pre-wrap}.resource-detail-image{display:block;width:100%;max-height:220px;object-fit:contain;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);margin-bottom:20px}
+.device-resource-table{min-width:820px}.device-resource-table th:nth-child(1),.device-resource-table td:nth-child(1){width:280px;min-width:280px;text-align:left}.device-resource-table th:nth-child(2),.device-resource-table td:nth-child(2){width:90px;text-align:center}.device-resource-table th:nth-child(3),.device-resource-table td:nth-child(3){width:110px;text-align:left}.device-resource-table th:nth-child(4),.device-resource-table td.domain-cell{width:240px;min-width:180px;text-align:left;white-space:normal}.device-resource-table th:nth-child(5),.device-resource-table td:nth-child(5){width:64px;text-align:center}.device-resource-table .company-record-link{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
+.domain-tag-list{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
+.ant-tag.tag-new{display:inline-flex;align-items:center;height:24px;line-height:21px;border-radius:var(--ant-border-radius);padding:0 12px;font-size:var(--ant-font-size-sm);white-space:nowrap;margin:0}
+.resource-name-cell{display:flex;align-items:center;gap:12px;min-width:0}.resource-name-cell>img,.resource-placeholder{flex:none;width:48px;height:36px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);object-fit:cover;border-radius:var(--ant-border-radius)}.resource-placeholder{display:grid;place-items:center;color:var(--ant-color-text-secondary)}.resource-name-cell .company-record-link{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
+.resource-point-list{border-top:1px solid var(--ant-color-border)}.resource-point-list>div{height:40px;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:space-between}.resource-point-list span{color:var(--ant-color-text-secondary)}.resource-point-list>p{color:var(--ant-color-text-secondary)}
+.resource-back{height:44px;display:flex;align-items:center;border-bottom:1px solid var(--ant-color-border);margin:0 0 16px}.resource-back .text-button{margin:0}
+.style-resource-panel{margin-top:16px}.style-resource-table{min-width:820px}.style-resource-table th:nth-child(1),.style-resource-table td:nth-child(1){width:260px;min-width:260px;text-align:left}.style-resource-table th:nth-child(2),.style-resource-table td:nth-child(2){width:140px}.style-resource-table th:nth-child(3),.style-resource-table td:nth-child(3){width:190px}.style-resource-table th:nth-child(4),.style-resource-table td:nth-child(4){width:130px}.style-resource-table th:nth-child(5),.style-resource-table td:nth-child(5){width:64px;text-align:center}.style-resource-table .company-record-link{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}.style-list-thumbnail{display:block;width:96px;height:54px;object-fit:cover;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius)}
+.style-drawer>img{display:block;width:100%;max-height:440px;object-fit:contain;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border)}.style-drawer dl{display:grid;grid-template-columns:110px 1fr;margin-top:20px}.style-drawer dt,.style-drawer dd{margin:0;padding:12px 0;border-bottom:1px solid var(--ant-color-border)}.style-drawer dt{color:var(--ant-color-text-secondary)}
+/* 生成过程只读终端 */
+.gen-right{display:flex;flex-direction:column;gap:16px}
+.console-panel .panel-body{padding:0}
+.gen-console{height:300px;max-height:42vh;overflow-y:auto;background:var(--ant-color-text-base);color:var(--ant-color-primary);font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;padding:12px 12px}
+.gen-console pre{margin:0;white-space:pre-wrap;word-break:break-word}
+.gen-console pre:empty:before{content:""}
+.modal-console{margin-top:12px;border:1px solid var(--ant-color-primary);border-radius:var(--ant-border-radius);overflow:hidden}
+.modal-console-head{background:var(--ant-color-text-base);color:var(--ant-color-primary-bg);font-size:var(--ant-font-size-sm);padding:8px 12px;border-bottom:1px solid var(--ant-color-primary)}
+.modal-console-body{background:var(--ant-color-text-base);color:var(--ant-color-primary);font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;margin:0;padding:12px 12px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
+
+/* ===== 视图切换(列表/卡片) ===== */
+.view-toggle{display:inline-flex;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);overflow:hidden}
+.view-toggle button{border:0;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);height:28px;padding:0 12px;font-size:var(--ant-font-size-sm)}
+.view-toggle button+button{border-left:1px solid var(--ant-color-border)}
+.view-toggle button.active{background:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}
+/* ===== 领域方案卡片视图 ===== */
+.domain-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(245px,1fr));gap:12px;padding:8px 0}
+.domain-card{border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);background:var(--ant-color-bg-container);padding:16px;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:.15s}
+.domain-card:hover{box-shadow:none;border-top-color:var(--ant-color-primary-active)}
+.domain-card>header{display:flex;align-items:center;justify-content:space-between;gap:8px}
+.domain-card h3{font-size:var(--ant-font-size);margin:0}
+.domain-card .badge{display:inline-block;align-self:flex-start;background:var(--ant-color-primary-bg);color:var(--ant-color-primary-active);border:1px solid var(--ant-color-primary);border-radius:var(--ant-border-radius);padding:4px 12px;font-size:var(--ant-font-size-sm)}
+.domain-card .meta{display:flex;gap:16px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.domain-card .meta b{color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);margin-right:4px}
+
+/* 领域方案 7 步向导 */
+.placeholder-block{display:flex;flex-direction:column;gap:12px;padding:8px 4px}
+.badge.todo{display:inline-block;align-self:flex-start;background:var(--ant-color-primary-bg);color:var(--ant-color-error);border:1px solid var(--ant-color-error-bg);border-radius:var(--ant-border-radius);padding:4px 12px;font-size:var(--ant-font-size-sm)}
+/* Cube 页面来源徽标 */
+.badge.src-upload{display:inline-flex;align-items:center;gap:4px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);border:1px solid var(--ant-color-primary-bg);border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm)}
+.badge.src-ai{display:inline-flex;align-items:center;gap:4px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);border:1px solid var(--ant-color-primary);border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm)}
+.badge.src-manual{display:inline-flex;align-items:center;gap:4px;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm)}
+
+/* 扩展菜单可点击项 */
+.extension-menu-section .extension-menu-item.clickable{cursor:pointer}
+.extension-menu-section .extension-menu-item.clickable:hover{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
+.extension-menu-section .extension-menu-item.clickable.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
+
+/* 仪表盘页面 */
+.dashboard-page{min-height:calc(100vh - 56px);padding:20px;background:var(--ant-color-bg-layout);display:flex;align-items:flex-start;justify-content:center}
+.dashboard-page img{display:block;max-width:100%;height:auto;background:var(--ant-color-bg-container);box-shadow:none;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius)}
+.domain-preview-canvas{background:var(--ant-color-text-base);height:64vh;border-radius:var(--ant-border-radius);overflow:auto}
+.domain-preview-canvas .dashboard-thumb-live{min-height:0}
+.domain-preview-feedback{margin-top:16px}
+.domain-preview-feedback textarea{width:100%;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px;resize:vertical;line-height:1.6;margin-top:8px}
+.domain-review-pages{display:grid;grid-template-columns:1fr;gap:12px}
+.domain-review-page{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);overflow:hidden;transition:border-color .15s,box-shadow .15s}
+.domain-review-page:hover{border-color:var(--ant-color-primary);box-shadow:none}
+.domain-review-page .dashboard-thumb-live{border-bottom:0;min-height:240px}
+.domain-review-page>div{padding:8px 12px;display:flex;align-items:center;justify-content:space-between;gap:8px}
+.domain-review-page h4{margin:0;font-size:var(--ant-font-size-sm)}
+.domain-review-page .review-zoom{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary);white-space:nowrap}
+
+.domain-card .card-actions{display:flex;gap:16px;margin-top:4px;padding-top:8px;border-top:1px solid var(--ant-color-border)}
+/* ===== 设备模版 ===== */
+.device-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px;padding:4px 0}
+.device-card{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:16px}
+.device-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
+.device-card-head b{font-size:var(--ant-font-size)}
+.cp-list{margin-top:12px}
+.cp-list>b{display:flex;align-items:center;gap:8px;font-size:var(--ant-font-size-sm)}
+.cp-list .data-table{margin-top:8px}
+.chk{display:flex;flex-wrap:wrap;gap:8px}
+.chk-item{display:inline-flex;align-items:center;gap:4px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:4px 8px;cursor:pointer}
+.chk-item input{margin:0;accent-color:var(--ant-color-primary)}
+/* ===== 方案详情:选用设备模版 ===== */
+.device-picker{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px}
+.picker-item{display:flex;align-items:center;gap:8px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:8px 12px;cursor:pointer}
+.picker-item.on{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}
+.picker-item input{margin:0;accent-color:var(--ant-color-primary)}
+.picker-name{font-weight:600;font-size:var(--ant-font-size-sm)}
+.picker-doms{margin-left:auto;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);max-width:42%;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+/* ===== 设备模版:图片与采集点编辑 ===== */
+.device-list{display:grid;grid-template-columns:1fr;gap:16px;padding:4px 0}
+.device-img-field{margin-top:8px;border:1px dashed var(--ant-color-border);border-radius:var(--ant-border-radius);width:160px;height:160px;background:var(--ant-color-bg-layout);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;position:relative}
+.device-img-field:hover{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}
+.device-img-field img{width:100%;height:100%;object-fit:cover;display:block}
+.device-img-field .device-img-empty{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);text-align:center;padding:0 8px}
+.cp-list-head{display:flex;align-items:center;justify-content:space-between;margin:12px 0 8px}
+.cp-list-head span{display:flex;gap:12px}
+.cp-editor td{padding:4px 8px;vertical-align:middle}
+.cp-editor input,.cp-editor select{width:100%;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}
+.cp-editor input:focus,.cp-editor select:focus{outline:1px solid var(--ant-color-primary)}
+.cp-range-custom{margin-top:4px}
+/* ===== 设备模板:报警规则 ===== */
+.alarm-rule-block{margin-top:20px;border-top:1px solid var(--ant-color-border);padding-top:8px}.alarm-rule-head{min-height:44px;display:flex;align-items:center;justify-content:space-between;gap:16px}.alarm-rule-head>b{font-size:var(--ant-font-size)}.alarm-rule-head>div{display:flex;align-items:center;gap:12px}.alarm-rule-head .button{height:32px;min-width:74px;margin:0}.alarm-rule-search{width:210px;height:32px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);display:flex;align-items:center;gap:8px;padding:0 8px}.alarm-rule-search:focus-within{border-color:var(--ant-color-primary)}.alarm-rule-search input{width:100%;border:0;outline:0;background:transparent}.alarm-rule-table{margin-top:0!important;border-top:1px solid var(--ant-color-border)}.alarm-rule-table th{height:40px;border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.alarm-rule-table td{height:48px;font-size:var(--ant-font-size-sm)}.alarm-rule-table input[type=checkbox]{accent-color:var(--ant-color-primary)}.alarm-name-link{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base)}.alarm-name-link:hover{color:var(--ant-color-primary)}.alarm-description-cell{max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.alarm-switch{width:25px;height:16px;padding:0;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-fill-secondary);position:relative;vertical-align:middle}.alarm-switch i{position:absolute;left:2px;top:2px;width:10px;height:10px;border-radius:50%;background:var(--ant-color-bg-container);transition:left .15s}.alarm-switch.on{border-color:var(--ant-color-primary);background:var(--ant-color-primary)}.alarm-switch.on i{left:11px}.alarm-row-actions{display:flex;align-items:center;gap:4px}.alarm-row-actions .ant-btn{width:30px;height:30px;padding:0;color:var(--ant-color-primary);font-size:var(--ant-font-size)}.alarm-row-actions .ant-btn-dangerous{color:var(--ant-color-error)}
+.alarm-rule-drawer .ant-drawer-header{min-height:56px;padding:0 24px;border-bottom:1px solid var(--ant-color-border)}.alarm-rule-drawer .ant-drawer-title{font-size:var(--ant-font-size);font-weight:600}.alarm-rule-drawer .ant-drawer-body{padding:28px 32px 32px;overflow:auto;background:var(--ant-color-bg-container)}.alarm-rule-drawer .ant-drawer-footer{height:56px;padding:12px 24px;border-top:1px solid var(--ant-color-border)}.alarm-rule-form{display:flex;flex-direction:column;gap:28px}.alarm-rule-form section>h3{margin:0 0 20px;padding-left:16px;border-left:4px solid var(--ant-color-primary);font-size:var(--ant-font-size);line-height:19px}.alarm-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px 40px;padding:0 20px}.alarm-form-grid label{display:grid;grid-template-columns:100px minmax(0,1fr);align-items:center;gap:0}.alarm-form-grid label.wide{grid-column:1/-1}.alarm-form-grid label>span{text-align:right;padding-right:16px;color:var(--ant-color-text-secondary)}.alarm-form-grid i,.alarm-sensitivity i{font-style:normal;color:var(--ant-color-error)}.alarm-form-grid input,.alarm-form-grid select{width:100%;height:34px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:0 12px;outline:0}.alarm-form-grid input:focus,.alarm-form-grid select:focus{border-color:var(--ant-color-primary)}.alarm-sensitivity{display:flex;align-items:center;gap:12px;padding:0 28px}.alarm-sensitivity>span{margin-right:8px}.alarm-sensitivity label{display:flex;align-items:center;gap:8px}.alarm-sensitivity input[type=radio]{accent-color:var(--ant-color-primary)}.alarm-sensitivity input[type=number]{width:55px;height:34px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 8px}.alarm-sensitivity input:disabled{background:var(--ant-color-bg-layout);color:var(--ant-color-text-disabled)}.alarm-sensitivity em{font-style:normal;color:var(--ant-color-text-secondary);margin-right:16px}.alarm-trigger-panel{border:1px solid var(--ant-color-border);margin:0 20px;min-height:250px}.alarm-trigger-panel>header{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--ant-color-border)}.alarm-trigger-panel>header b{font-size:var(--ant-font-size)}.alarm-trigger-panel>header>span{display:flex;align-items:center;gap:8px}.alarm-trigger-panel>header .button{height:32px;min-width:72px;margin:0}.alarm-trigger-table{table-layout:fixed}.alarm-trigger-table th{border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.alarm-trigger-table th:nth-child(1){width:120px}.alarm-trigger-table th:nth-child(2){width:125px}.alarm-trigger-table th:nth-child(3){width:110px}.alarm-trigger-table th:nth-child(4){width:160px}.alarm-trigger-table th:nth-child(5){width:90px}.alarm-trigger-table th:nth-child(6){width:auto}.alarm-trigger-table th:nth-child(7){width:64px}.alarm-trigger-table td{height:48px;padding:8px 8px}.alarm-trigger-table input,.alarm-trigger-table select{width:100%;height:30px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 8px;outline:0}.alarm-trigger-table input:focus,.alarm-trigger-table select:focus{border-color:var(--ant-color-primary)}.alarm-trigger-empty{height:130px;display:grid;place-items:center;color:var(--ant-color-text-secondary)}
+@media(max-width:900px){.alarm-rule-drawer .ant-drawer-content-wrapper{width:100vw!important}.alarm-form-grid{grid-template-columns:1fr}.alarm-form-grid label.wide{grid-column:auto}.alarm-sensitivity{flex-wrap:wrap}.alarm-rule-table{min-width:900px}.alarm-rule-block{overflow:auto}}
+/* ===== 设备模版:列表视图 ===== */
+.list-toolbar{margin-bottom:12px}
+.list-toolbar .search{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:32px;padding:0 12px;width:320px;background:var(--ant-color-bg-container)}
+.list-toolbar .search:focus{outline:1px solid var(--ant-color-primary)}
+.device-tpl-list tbody tr{cursor:pointer}
+.device-tpl-list tbody tr:hover{background:var(--ant-color-bg-layout)}
+.tpl-thumb{width:48px;height:34px;object-fit:cover;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);display:block;background:var(--ant-color-bg-layout)}
+.tpl-thumb-empty{color:var(--ant-color-text-disabled)}
+
+/* ===== 自定义确认 / 输入弹窗(替代原生 confirm/prompt,居中显示) ===== */
+.dlg-backdrop{position:fixed;inset:0;z-index:90;background:color-mix(in srgb,var(--ant-color-bg-mask) 35%,transparent);display:flex;align-items:center;justify-content:center;padding:20px}
+.dlg-box{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:0 12px 36px color-mix(in srgb,var(--ant-color-text-base) 20%,transparent);width:min(420px,100%);max-width:100%;padding:20px 24px}
+.dlg-box p{margin:0 0 20px;font-size:var(--ant-font-size-sm);line-height:1.6;color:var(--ant-color-text-base);white-space:pre-wrap;word-break:break-word}
+.dlg-box input{width:100%;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:32px;padding:0 8px;font-size:var(--ant-font-size-sm)}
+.dlg-box input:focus{outline:1px solid var(--ant-color-primary)}
+.dlg-actions{display:flex;justify-content:flex-end;gap:12px}
+.dlg-actions .button{margin-left:0}
+/* 领域方案库 · 管理领域 */
+.domain-manage-list{display:flex;flex-direction:column;gap:8px}
+.domain-manage-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container)}
+.domain-manage-name{font-weight:600;color:var(--ant-color-text-base)}
+.domain-manage-row .muted{font-size:var(--ant-font-size-sm)}
+.text-button.danger{color:var(--ant-color-error)}
+.text-button.danger:hover{text-decoration:underline}
+.domain-create-dialog h2{margin:0 0 8px;font-size:var(--ant-font-size-heading-4)}.domain-create-dialog p{margin:0 0 16px;color:var(--ant-color-text-secondary)}.domain-create-dialog .dlg-actions{margin-top:20px}.domain-create-dialog .button{min-width:96px;margin:0}
+.system-settings-page{max-width:920px;padding-top:16px}.system-settings-page>.panel{margin:0}.system-settings-page .panel-head{height:52px}.settings-form{max-width:680px;display:flex;flex-direction:column;gap:20px;padding:8px 8px 16px}.settings-form .field{gap:8px}.settings-form .field>span{font-size:var(--ant-font-size)}.settings-form input,.settings-form select{width:100%;height:40px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;background:var(--ant-color-bg-container)}.settings-form input[readonly]{background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary)}.settings-note{padding:16px 16px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);display:flex;flex-direction:column;gap:4px}.settings-note span{color:var(--ant-color-text-secondary)}.settings-actions{display:flex;justify-content:flex-start}.settings-actions .button{margin:0}.settings-table-wrap{overflow-x:auto}.settings-domain-table{table-layout:auto;min-width:680px}.settings-domain-table th:first-child,.settings-domain-table td:first-child{width:260px;min-width:260px;text-align:left}.settings-domain-table th:nth-child(2),.settings-domain-table td:nth-child(2){width:auto}.settings-domain-table th:nth-child(3),.settings-domain-table td:nth-child(3){width:130px}.settings-domain-table th:nth-child(4),.settings-domain-table td:nth-child(4){width:100px;text-align:center}.settings-domain-table .text-button{margin:0}
+
+/* 领域方案库 · 左侧导航栏「领域分类」行内编辑(删除× / 新增+) */
+.domain-nav-row{display:flex;align-items:stretch;flex-wrap:nowrap}
+.domain-nav-row>button:not(.domain-nav-del):not(.domain-nav-ok){flex:1;min-width:0;text-align:left;height:41px;border:0;background:transparent;padding:0 16px;display:flex;align-items:center;color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);white-space:nowrap}
+.domain-nav-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.domain-nav-row>button b{width:18px;color:var(--ant-color-text-secondary);margin-right:8px;font-weight:400;flex:0 0 auto}
+.domain-nav-row>button:hover{background:var(--ant-color-fill-quaternary)}
+.domain-nav-row.active{background:var(--ant-color-primary-bg)}
+.domain-nav-row>button.active:not(.domain-nav-del):not(.domain-nav-ok){background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
+.domain-nav-row.active>button b,.domain-nav-row>button.active b{color:var(--ant-color-primary)}
+.domain-nav-del{border:0;background:transparent;width:30px;color:var(--ant-color-text-disabled);font-size:var(--ant-font-size);line-height:1;flex:0 0 auto}
+.domain-nav-del:hover{color:var(--ant-color-error);background:var(--ant-color-bg-layout)}
+.domain-nav-ok{border:0;background:transparent;width:30px;color:var(--ant-color-success);font-size:var(--ant-font-size);flex:0 0 auto}
+.domain-nav-ok:hover{background:var(--ant-color-bg-layout)}
+.domain-nav-add-cta{display:block}
+.domain-nav-add{height:34px;border:0;background:transparent;text-align:left;padding:0 16px;display:flex;align-items:center;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm);width:100%}
+.domain-nav-add:hover{background:var(--ant-color-primary-bg)}
+.domain-nav-add b{margin-right:8px}
+.domain-nav-row.add{align-items:center;padding:4px 8px}
+.domain-nav-input{flex:1;min-width:0;margin:0 4px;height:28px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 8px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container)}
+.domain-nav-input:focus{outline:1px solid var(--ant-color-primary);border-color:var(--ant-color-primary)}
+
+/* ===== 方案打包 / 方案概览(v0.32) ===== */
+.gen-overview{display:block}
+.gen-overview .panel{margin-bottom:0}
+.overview-info{display:grid;grid-template-columns:1fr 1fr;gap:12px 20px;padding:4px 4px 16px;border-bottom:1px solid var(--ant-color-border);margin-bottom:12px}
+.overview-info>div{display:flex;flex-direction:column;gap:4px}
+.overview-info span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);font-weight:600;letter-spacing:.3px}
+.overview-info b{font-size:var(--ant-font-size);color:var(--ant-color-text-base)}
+.overview-info .overview-desc{grid-column:1 / -1}
+.overview-info .overview-desc p{margin:0;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.6;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 8px}
+.overview-tabs{margin:0 0 12px}
+.overview-tabs button{padding:0 16px;font-size:var(--ant-font-size-sm)}
+.overview-body{min-height:120px}
+.overview-body .muted{padding:12px 4px}
+.dev-list{display:flex;flex-direction:column;gap:8px}
+.dev-row{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);overflow:hidden;background:var(--ant-color-bg-container)}
+.dev-row-head{width:100%;border:0;background:transparent;display:flex;align-items:center;gap:12px;padding:12px 12px;text-align:left;cursor:pointer}
+.dev-row-head:hover{background:var(--ant-color-bg-layout)}
+.dev-row-head b{font-size:var(--ant-font-size-sm);flex:1}
+.dev-count{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius);padding:4px 8px}
+.dev-caret{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+.dev-detail{border-top:1px solid var(--ant-color-border);padding:12px 12px;background:var(--ant-color-bg-layout)}
+.dev-desc{margin:0 0 8px;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.7}
+.dev-cp b{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base);display:block;margin-bottom:4px}
+.dev-cp ul{margin:0;padding-left:20px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.8}
+.doc-list{display:flex;flex-direction:column;gap:8px}
+.doc-item{display:flex;align-items:baseline;gap:12px;padding:8px 12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container)}
+.doc-item b{font-size:var(--ant-font-size-sm);flex:1}
+.doc-item .muted{font-size:var(--ant-font-size-sm);white-space:nowrap}
+.ops-placeholder{display:flex;flex-direction:column;gap:12px;padding:20px;border:1px dashed var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout)}
+.ops-placeholder .status{align-self:flex-start}
+.ops-placeholder p{margin:0;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.7}
+/* ===== 方案打包示意图弹窗(v0.32) ===== */
+.pkg-modal{width:560px}
+.pkg-body{padding:20px}
+.pkg-body .hint{margin:0 0 16px}
+.pkg-box{display:flex;justify-content:center;padding:8px 0 16px}
+.pkg-svg{width:240px;height:auto;filter:drop-shadow(0 8px 16px rgba(120,80,20,.18))}
+.pkg-items{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+.pkg-item{display:flex;align-items:flex-start;gap:12px;padding:12px 12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);box-shadow:none}
+.pkg-item:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}
+.pkg-icon{width:34px;height:34px;flex:0 0 auto;border-radius:var(--ant-border-radius);background:linear-gradient(135deg,var(--ant-color-bg-layout),var(--ant-color-primary-bg));color:var(--ant-color-primary);display:grid;place-items:center;font-size:var(--ant-font-size)}
+.pkg-item-text{display:flex;flex-direction:column;gap:4px;min-width:0}
+.pkg-item-text b{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base)}
+.pkg-item-text small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.5}
+
+
+/* WISE-IoT Desk header — aligned to the reference application's native header. */
+.topbar.wise-header{height:56px;padding:0 8px 0 12px;gap:0;border-top:4px solid #008cd6;border-bottom:1px solid #c8c8c8;background:#fff;box-shadow:none;overflow:hidden;color:#787878}
+.wise-header button{border:0;background:transparent;padding:0;box-shadow:none}
+.wise-menu-button{flex:0 0 24px;width:24px;height:24px;margin-right:12px;color:#787878;display:grid;place-items:center}
+.wise-menu-button img{display:block;width:24px;height:24px}
+.wise-logo-link{flex:0 0 123px;width:123px;height:40px;display:block}
+.wise-logo-link img{display:block;width:123px;height:40px}
+.wise-product-switch{flex:0 0 auto;height:48px;margin-left:12px;display:flex;align-items:center;color:#787878;white-space:nowrap}
+.wise-product-switch>img:first-child{width:24px;height:24px;margin-right:8px}
+.wise-product-switch>span,.wise-product-switch>strong{font-family:Roboto,"Noto Sans SC","Microsoft YaHei",Arial,sans-serif;font-size:16px;line-height:24px;font-weight:700;color:#787878}
+.wise-product-switch>strong{margin-left:6px}
+.wise-product-switch>img:last-child{width:24px;height:24px}
+.wise-header-actions{height:48px;margin-left:auto;display:flex;align-items:center;justify-content:flex-end;flex:0 0 auto}
+.wise-header-actions>button:not(.wise-avatar){flex:0 0 40px;width:40px;height:40px;display:grid;place-items:center;border-radius:0}
+.wise-header-actions>button:not(.wise-avatar):hover{background:#f5f5f5}
+.wise-header-actions>button>img{display:block;width:24px;height:24px}
+.wise-avatar{flex:0 0 24px;width:24px;height:24px;margin:0 8px;border-radius:50%!important;background:#dff3fc!important;color:#008cd6!important;font-size:12px;line-height:24px;text-align:center}
+.wise-powered-divider{flex:0 0 1px;width:1px;height:24px;background:#dedede;margin:0 4px 0 5px}
+.wise-powered-logo{display:block;flex:0 0 122px;width:122px;height:48px;object-fit:contain}
+@media(max-width:1120px){.wise-powered-divider,.wise-powered-logo{display:none}.wise-avatar{margin-right:8px}}
+@media(max-width:700px){.wise-header-secondary{display:none!important}.wise-product-switch>strong{display:none}.wise-header-actions>button:not(.wise-avatar){width:36px;flex-basis:36px}}
+@media(max-width:560px){.wise-logo-link{display:none}.wise-product-switch{margin-left:4px}.wise-header-actions>button:nth-child(1),.wise-header-actions>button:nth-child(2){display:none}}
+
+/* 当前站点资产组编辑器:直接进入详情,不再经过资产组列表。 */
+.asset-editor-loading{min-height:680px;border:1px solid var(--ant-color-border);border-top:0;background:var(--ant-color-bg-container);display:flex;align-items:center;justify-content:center;gap:16px;color:var(--ant-color-text-secondary)}.asset-editor-loading.error{color:var(--ant-color-error)}
+.manual-asset-editor{border-top:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.asset-editor-title{height:58px;border:1px solid var(--ant-color-border);border-top:0;padding:0 20px}.asset-editor-title>div{display:flex;align-items:baseline;gap:20px}.asset-editor-title h2{font-size:var(--ant-font-size-heading-4);font-weight:600;color:var(--ant-color-text-base)}.asset-editor-title>div>span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-editor-workbench{min-height:680px;height:calc(100vh - 306px);grid-template-columns:232px minmax(720px,1fr);border-color:var(--ant-color-border)}
+.asset-editor-side{background:var(--ant-color-bg-container);border-right:1px solid var(--ant-color-border)}.asset-editor-side>.field{padding:16px 20px 16px}.asset-editor-side>.field>span{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base);margin-bottom:8px}.asset-editor-side>.field input{height:36px;border:1px solid var(--ant-color-border);padding:0 8px}.asset-batch-entry{height:72px;padding:0 20px;font-size:var(--ant-font-size);color:var(--ant-color-text-base)}.asset-batch-entry:hover{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.asset-template-panel{padding:16px 20px;gap:12px}.asset-template-panel>strong{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.asset-template-panel>select{height:34px}.asset-template-panel>button{min-height:50px;border:0;background:transparent;grid-template-columns:24px 1fr;padding:8px 8px}.asset-template-panel>button:hover{background:var(--ant-color-bg-layout);border:0}.asset-template-panel>button>span{font-size:var(--ant-font-size)}.asset-template-panel>button>small{color:var(--ant-color-text-secondary)}
+.asset-tree-workspace{background:var(--ant-color-bg-container);display:flex;flex-direction:column;min-height:0}.asset-editor-toolbar{height:49px;min-height:49px;padding:0 16px;position:relative}.asset-editor-toolbar>div{gap:4px}.asset-tool-group,.asset-canvas-tools{display:flex;align-items:center}.asset-tool-button.ant-btn{width:34px;min-width:34px;height:32px;padding:0;border-radius:var(--ant-border-radius);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}.asset-tool-button.ant-btn:not(:disabled){color:var(--ant-color-primary)}.asset-tool-button.ant-btn:hover:not(:disabled){color:var(--ant-color-primary-hover);background:var(--ant-color-bg-layout)}.asset-tool-button.primary.ant-btn{color:var(--ant-color-primary)}.asset-tool-divider{height:22px;border-left:1px solid var(--ant-color-border);margin:0 4px}.asset-view-switch{position:absolute;left:50%;transform:translateX(-50%);height:32px;border:1px solid var(--ant-color-border);display:flex!important;gap:0!important}.asset-view-switch button{width:38px;border:0;border-right:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}.asset-view-switch button:last-child{border-right:0}.asset-view-switch button.active{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.asset-tree-search{width:172px;height:30px;margin-left:8px;color:var(--ant-color-text-secondary)}.asset-tree-search input{font-size:var(--ant-font-size-sm)}
+.asset-tree-canvas{flex:1;min-height:625px;padding:44px;overflow:auto;background:var(--ant-color-bg-layout);cursor:grab;touch-action:none;user-select:none}.asset-tree-canvas.dragging{cursor:grabbing}.asset-tree-stage{width:max-content;min-width:100%;min-height:100%;display:flex;align-items:center;justify-content:center;transform-origin:center center;transition:transform .12s ease}.dragging .asset-tree-stage{transition:none}.asset-node-tree{display:flex;flex-direction:column;justify-content:center;align-items:flex-start;gap:28px;min-width:max-content}.asset-tree-branch{display:flex;flex-direction:row;align-items:center;position:relative}.asset-node-card{width:174px;min-height:42px;grid-template-columns:24px 1fr 22px;grid-template-rows:1fr 1fr;padding:8px 8px;border:0;border-radius:var(--ant-border-radius);box-shadow:none;background:var(--ant-color-bg-container);cursor:pointer}.asset-node-card>span{font-size:var(--ant-font-size)}.asset-node-card>b{font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary)}.asset-node-card>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-node-card>button{grid-column:3;grid-row:1/3;align-self:center;justify-self:end;width:19px;height:19px;border:0;border-radius:50%;padding:0;background:var(--ant-color-text-disabled);color:var(--ant-color-text-light-solid);display:grid;place-items:center;font-size:var(--ant-font-size-sm)}.asset-node-card>button:hover{background:var(--ant-color-primary)}.asset-node-card:hover,.asset-node-card.selected{border:0;box-shadow:none}.asset-node-card.matched{box-shadow:none}.asset-tree-children{margin:0 0 0 36px;padding:0 0 0 36px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px;position:relative}.asset-tree-children:before{left:0;top:21px;bottom:21px;height:auto;border-left:1px solid var(--ant-color-primary)}.asset-tree-children>.asset-tree-branch:before{content:"";position:absolute;left:-34px;top:50%;width:34px;height:0;border-top:1px solid var(--ant-color-primary)}.asset-tree-children>.asset-tree-branch:not(:only-child):after{display:none}.asset-tree-branch>.asset-node-card+.asset-tree-children:after{content:"";position:absolute;left:-34px;top:50%;width:34px;border-top:1px solid var(--ant-color-primary)}.asset-tree-empty{margin:80px auto;color:var(--ant-color-text-secondary)}.asset-tree-empty b{font-weight:600}
+.asset-node-list{flex:1;min-height:625px;overflow:auto;background:var(--ant-color-bg-container)}.asset-node-list-head,.asset-node-list>button{height:43px;display:grid;grid-template-columns:minmax(260px,1.5fr) 120px minmax(180px,1fr) 90px;align-items:center;text-align:left;border:0;border-bottom:1px solid var(--ant-color-border);padding:0 16px;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary)}.asset-node-list-head{height:40px;background:var(--ant-color-bg-layout);font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary)}.asset-node-list>button:hover,.asset-node-list>button.selected{background:var(--ant-color-bg-layout)}.asset-node-list>button.selected{box-shadow:none}.asset-node-list>button>span:first-child{color:var(--ant-color-text-base)}
+@media(max-width:1100px){.asset-editor-workbench{grid-template-columns:210px minmax(660px,1fr)}.asset-editor-title span{display:none}.asset-tree-search{width:130px}.asset-zoom-value{display:none}}
+
+/* Asset 节点配置抽屉:对齐原资产设定的三 Tab 信息架构。 */
+.asset-node-drawer .ant-drawer-header{min-height:56px;padding:0 24px;border-bottom:1px solid var(--ant-color-border)}.asset-node-drawer .ant-drawer-title{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.asset-node-drawer .ant-drawer-body{padding:0;overflow:auto}.asset-node-drawer .ant-drawer-footer{height:56px;padding:12px 24px;border-top:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.node-drawer-footer{display:flex;justify-content:flex-end;align-items:center;gap:8px}.node-drawer-footer .button{margin:0}
+.asset-node-drawer .node-drawer-tabs{height:49px;margin:0;padding-left:20px;gap:0;border-bottom:1px solid var(--ant-color-border)}.asset-node-drawer .node-drawer-tabs button{height:49px;padding:0 20px;border-right:1px solid var(--ant-color-border);border-bottom:3px solid transparent;color:var(--ant-color-text-secondary)}.asset-node-drawer .node-drawer-tabs button:first-child{padding-left:0;margin-right:20px}.asset-node-drawer .node-drawer-tabs button.active{color:var(--ant-color-text-secondary);border-bottom-color:var(--ant-color-primary)}
+.node-config-page{padding:24px 24px 48px;display:flex;flex-direction:column;gap:40px}.node-config-section>h3{height:22px;line-height:22px;margin:0 0 24px;padding-left:16px;border-left:4px solid var(--ant-color-primary);font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.node-config-section>h4,.binding-parameter-head h4{margin:0 0 12px;font-size:var(--ant-font-size);color:var(--ant-color-text-secondary)}.node-id-row{display:grid;grid-template-columns:105px 1fr;align-items:center;max-width:420px;margin:0 0 20px 48px;color:var(--ant-color-text-secondary)}.node-id-row b{font-weight:400;color:var(--ant-color-text-secondary)}.node-basic-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 36px}.node-basic-grid .field,.node-property-grid .field{display:grid;grid-template-columns:105px minmax(0,1fr);align-items:center;gap:12px}.node-basic-grid .field>span,.node-property-grid .field>span{font-size:var(--ant-font-size);font-weight:400;color:var(--ant-color-text-secondary);text-align:right}.node-basic-grid .field input,.node-basic-grid .field select,.node-property-grid .field input{width:100%;height:34px;padding:0 12px;border:1px solid var(--ant-color-border)}.node-basic-grid .field:nth-child(5){grid-column:1/-1}.node-image-row{display:flex;align-items:flex-start;gap:12px;margin-left:20px}.node-image-preview{width:108px;height:108px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);display:grid;place-items:center;overflow:hidden;color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-heading-2);cursor:pointer}.node-image-preview img{width:100%;height:100%;object-fit:contain}.node-image-row>div{display:flex;flex-direction:column;align-items:flex-start;gap:4px}.node-upload-link{color:var(--ant-color-primary);font-size:var(--ant-font-size);cursor:pointer}.node-image-row small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-disabled)}.node-image-row .text-button{margin:4px 0 0}.node-config-section .ant-qrcode{margin-left:20px;padding:0;background:var(--ant-color-bg-container)}
+.node-property-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 36px}.node-property-grid .field{min-width:0}.node-property-grid .field:nth-last-child(2){grid-column:auto}.node-property-grid input[type="date"]{color:var(--ant-color-text-secondary)}
+.binding-page{gap:28px}.edge-component-table{border:1px solid var(--ant-color-border);max-height:200px;overflow:auto}.edge-component-table>header,.edge-component-table>div{min-height:45px;display:grid;grid-template-columns:1.2fr 1fr 1fr 1.2fr 88px;align-items:center;padding:0 8px;border-bottom:1px solid var(--ant-color-border)}.edge-component-table>header{min-height:38px;background:var(--ant-color-bg-layout);border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary)}.edge-component-table>div:last-child{border-bottom:0}.edge-component-table>div.active{background:var(--ant-color-bg-layout)}.edge-component-table b{font-weight:400}.edge-component-table>div>span:last-child{display:flex;justify-content:flex-end;gap:8px}.edge-component-table button{width:28px;height:28px;border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size)}.edge-component-table button:hover{background:var(--ant-color-bg-layout)}.binding-component-tabs{height:42px;display:flex;align-items:flex-end;justify-content:center;margin-bottom:12px}.binding-component-tabs button{height:34px;padding:0 20px;border:0;border-right:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);color:var(--ant-color-text-disabled)}.binding-component-tabs button:last-child{border-right:0}.binding-component-tabs button.active{color:var(--ant-color-primary);font-weight:600}.binding-parameter-head{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--ant-color-border);border-bottom:0;padding:8px 12px}.binding-parameter-head h4{margin:0}.binding-parameter-head .button{height:30px;margin:0}.binding-parameter-table{border:1px solid var(--ant-color-border);overflow:auto}.binding-parameter-table>header,.binding-parameter-table>div:not(.binding-empty){min-width:760px;display:grid;grid-template-columns:1fr 1.2fr 70px 64px 1fr 1.2fr 42px;align-items:center;gap:8px;padding:0 8px}.binding-parameter-table>header{height:38px;background:var(--ant-color-bg-layout);border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm);font-weight:600}.binding-parameter-table>div:not(.binding-empty){min-height:44px;border-bottom:1px solid var(--ant-color-border)}.binding-parameter-table input{width:100%;height:30px;border:1px solid var(--ant-color-border);padding:0 8px}.binding-parameter-table .trash{color:var(--ant-color-error)}.binding-empty{height:120px;display:grid;place-items:center;color:var(--ant-color-text-secondary)}.binding-import-backdrop{z-index:1300}.binding-import-dialog{width:min(620px,calc(100vw - 48px))}
+@media(max-width:1200px){.asset-node-drawer .ant-drawer-content-wrapper{width:78vw!important}.node-basic-grid,.node-property-grid{grid-template-columns:1fr}.node-basic-grid .field:nth-child(5){grid-column:auto}}@media(max-width:760px){.asset-node-drawer .ant-drawer-content-wrapper{width:100vw!important}.node-basic-grid .field,.node-property-grid .field{grid-template-columns:90px minmax(0,1fr)}.node-config-page{padding:20px 16px}.edge-component-table>header,.edge-component-table>div{grid-template-columns:1.2fr 1fr 1fr 72px}.edge-component-table>header>span:nth-child(4),.edge-component-table>div>span:nth-child(4){display:none}}
+
+/* 领域方案:从分步编辑调整为领域能力展示。 */
+.domain-showcase-page{display:flex;flex-direction:column;gap:16px}.domain-showcase-intro{min-height:92px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);display:grid;grid-template-columns:150px 1fr auto;align-items:center;padding:16px 20px;gap:24px}.domain-showcase-intro>div:first-child{display:flex;align-items:baseline;gap:8px;color:var(--ant-color-text-secondary)}.domain-showcase-intro>div:first-child b{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-text-base)}.domain-showcase-intro>p{margin:0;padding-left:24px;border-left:1px solid var(--ant-color-border);color:var(--ant-color-text-secondary);line-height:1.8}.domain-showcase-total{display:flex;gap:20px}.domain-showcase-total span{display:flex;flex-direction:column;gap:4px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-showcase-total b{color:var(--ant-color-text-base);font-size:var(--ant-font-size)}.domain-showcase-filter{height:52px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:flex;align-items:center;gap:12px;padding:12px 12px}.domain-showcase-filter label{width:300px;height:32px;border:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 12px;color:var(--ant-color-text-secondary)}.domain-showcase-filter label:focus-within{border-color:var(--ant-color-primary)}.domain-showcase-filter label .station-ui-input.ant-input{flex:1;min-width:0;width:auto;height:100%;border:0;outline:0;padding-left:8px}.domain-showcase-filter select{height:32px;min-width:180px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 32px 0 12px}.domain-showcase-filter>span{margin-left:auto;color:var(--ant-color-text-secondary)}.domain-showcase-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.domain-showcase-card{min-height:340px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);padding:20px;display:flex;flex-direction:column;cursor:pointer;transition:box-shadow .15s,border-color .15s}.domain-showcase-card:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}.domain-showcase-card.violet{border-top-color:var(--ant-color-primary)}.domain-showcase-card.green{border-top-color:var(--ant-color-success)}.domain-showcase-card.gray{border-top-color:var(--ant-color-border)}.domain-showcase-card>header{height:42px;display:flex;align-items:flex-start;justify-content:space-between}.domain-showcase-card>header>div:last-child{display:flex;align-items:center;gap:8px}.domain-showcase-mark{font-size:var(--ant-font-size-sm);font-weight:600;letter-spacing:1px;color:var(--ant-color-primary);background:var(--ant-color-bg-layout);padding:8px 8px}.violet .domain-showcase-mark{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.green .domain-showcase-mark{color:var(--ant-color-success);background:var(--ant-color-bg-layout)}.gray .domain-showcase-mark{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}.domain-showcase-card h2{font-size:var(--ant-font-size);margin:16px 0 8px;color:var(--ant-color-text-base)}.domain-showcase-card>p{height:44px;margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.75;overflow:hidden}.domain-showcase-card>img.domain-showcase-photo{display:block;width:100%;aspect-ratio:2/1;object-fit:cover;margin:10px 0 0;background:var(--ant-color-bg-layout)}.domain-showcase-capabilities{display:grid;grid-template-columns:1fr 1fr;margin-top:16px;border:1px solid var(--ant-color-border)}.domain-showcase-capabilities>span{min-height:62px;padding:12px;display:grid;grid-template-columns:22px 1fr;grid-template-rows:auto auto;align-items:center;border-right:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border)}.domain-showcase-capabilities>span:nth-child(2n){border-right:0}.domain-showcase-capabilities>span:nth-last-child(-n+2){border-bottom:0}.domain-showcase-capabilities .anticon{grid-row:1/3;color:var(--ant-color-primary);font-size:var(--ant-font-size)}.domain-showcase-capabilities b{font-size:var(--ant-font-size-sm)}.domain-showcase-capabilities small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-showcase-card>footer{margin-top:auto;padding-top:16px;display:flex;align-items:center;justify-content:space-between;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-showcase-card>footer button{height:30px;border:0;background:transparent;color:var(--ant-color-primary);padding:0;font-weight:600}.domain-showcase-card>footer button:hover{text-decoration:underline}
+
+.domain-capability-detail{display:flex;flex-direction:column;gap:12px}.domain-capability-back{align-self:flex-start;border:0;background:transparent;color:var(--ant-color-primary);padding:0;height:28px}.domain-capability-head{min-height:156px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);padding:24px;display:grid;grid-template-columns:76px 1fr auto;gap:20px;align-items:center}.domain-capability-head.violet{border-top-color:var(--ant-color-primary)}.domain-capability-head.green{border-top-color:var(--ant-color-success)}.domain-capability-head.gray{border-top-color:var(--ant-color-border)}.domain-capability-code{width:76px;height:76px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-weight:600;font-size:var(--ant-font-size-sm);letter-spacing:.8px}.domain-capability-head.violet .domain-capability-code{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.domain-capability-head.green .domain-capability-code{color:var(--ant-color-success);background:var(--ant-color-bg-layout)}.domain-capability-head.gray .domain-capability-code{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}.domain-capability-copy>div{display:flex;align-items:center;gap:8px}.domain-capability-copy>div>span:last-child{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-capability-copy h2{margin:8px 0 4px;font-size:var(--ant-font-size-heading-3)}.domain-capability-copy p{margin:0;color:var(--ant-color-text-secondary);line-height:1.6}.domain-capability-copy small{display:block;margin-top:8px;color:var(--ant-color-text-secondary)}.domain-capability-actions{display:flex;align-items:center;gap:4px}.domain-capability-summary{height:66px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:grid;grid-template-columns:repeat(4,1fr)}.domain-capability-summary span{display:flex;align-items:baseline;justify-content:center;gap:8px;border-right:1px solid var(--ant-color-border);color:var(--ant-color-text-secondary)}.domain-capability-summary span:last-child{border-right:0}.domain-capability-summary b{font-size:var(--ant-font-size-heading-3);color:var(--ant-color-text-base)}.domain-capability-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.domain-capability-card{min-height:286px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:flex;flex-direction:column}.domain-capability-card>header{height:72px;padding:12px 16px;border-bottom:1px solid var(--ant-color-border);display:grid;grid-template-columns:38px 1fr auto;align-items:center;gap:12px}.domain-capability-icon{width:36px;height:36px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;font-size:var(--ant-font-size-heading-4)}.domain-capability-card>header h3{margin:0 0 4px;font-size:var(--ant-font-size)}.domain-capability-card>header p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-capability-card>header>strong{font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);padding:4px 8px}.domain-capability-body{min-height:158px;padding:16px;background:var(--ant-color-bg-layout)}.domain-capability-card>footer{height:50px;margin-top:auto;border-top:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:flex-end;padding:0 12px;gap:8px}.domain-capability-card>footer button{height:29px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);padding:0 12px}.domain-capability-card>footer button:first-child{color:var(--ant-color-primary);border-color:var(--ant-color-primary-bg)}.domain-capability-card>footer button.capability-edit-button{margin-right:auto}.domain-capability-card>footer button:hover{border-color:var(--ant-color-primary);color:var(--ant-color-primary)}.capability-device-tags{display:flex;flex-wrap:wrap;gap:8px}.capability-device-tags span{height:28px;display:flex;align-items:center;gap:8px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:0 8px;color:var(--ant-color-text-secondary)}.capability-device-tags b{min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.capability-mini-tree{margin-top:16px;height:62px;display:flex;align-items:center;position:relative;padding-left:4px}.capability-mini-tree>i{width:10px;height:10px;border:2px solid var(--ant-color-primary);border-radius:50%;margin-right:8px}.capability-mini-tree>b{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px 12px;font-size:var(--ant-font-size-sm)}.capability-mini-tree>span{position:relative;margin-left:28px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px 8px;font-size:var(--ant-font-size-sm)}.capability-mini-tree>span:before{content:"";position:absolute;left:-29px;top:50%;width:28px;border-top:1px solid var(--ant-color-primary)}.capability-page-pair{display:grid;grid-template-columns:1fr 1fr;gap:16px;height:128px}.mini-dashboard,.mini-twin{position:relative;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);padding:20px 8px 8px;display:grid;grid-template-columns:1.2fr .8fr;gap:4px}.mini-dashboard:before{content:"仪表板";position:absolute;left:8px;top:5px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.mini-dashboard i{background:var(--ant-color-primary)}.mini-dashboard i:nth-child(2),.mini-dashboard i:nth-child(3){background:var(--ant-color-primary-bg)}.mini-twin{overflow:hidden;background:linear-gradient(160deg,var(--ant-color-bg-layout),var(--ant-color-bg-layout))}.mini-twin:before{content:"SC 数字孪生";position:absolute;left:8px;top:5px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.mini-twin i{width:38px;height:27px;background:var(--ant-color-primary);transform:skewY(-18deg);align-self:end;box-shadow:none}.mini-twin span{position:absolute;right:7px;bottom:5px;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.capability-file-list{display:flex;flex-direction:column;gap:8px}.capability-file-list span{height:36px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;padding:0 8px;color:var(--ant-color-text-secondary)}.capability-file-list b{width:24px;height:24px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;margin-right:8px;font-size:var(--ant-font-size-sm)}.capability-menu-preview{width:180px;min-height:126px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);display:flex;flex-direction:column;padding:8px;margin:auto}.capability-menu-preview>b{height:27px;background:var(--ant-color-bg-container);padding:8px 8px;font-size:var(--ant-font-size-sm)}.capability-menu-preview>span{height:22px;padding:4px 12px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.capability-menu-preview>span:first-of-type{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}
+
+.domain-capability-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.domain-capability-card{height:calc(100vh - 450px);min-height:500px;max-height:680px}.domain-capability-card>header{height:auto;min-height:82px;padding:12px 16px;grid-template-columns:38px minmax(0,1fr)}.domain-capability-card>header>strong{grid-column:1/-1;justify-self:end}.domain-capability-body.capability-empty-slot{flex:1;min-height:0;padding:0;background:var(--ant-color-bg-container)}.domain-capability-body.capability-asset-content,.domain-capability-body.capability-page-content,.domain-capability-body.capability-twin-content,.domain-capability-body.capability-knowledge-content{flex:1;min-height:0;padding:16px;background:var(--ant-color-bg-container);overflow-y:auto;scrollbar-gutter:stable}.capability-section-title{height:30px;display:flex;align-items:flex-start;justify-content:space-between;border-bottom:1px solid var(--ant-color-border);margin-bottom:12px}.capability-section-title b{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.capability-section-title span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.device-template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:8px}.device-template-tile{aspect-ratio:1/1;min-height:88px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:8px;text-align:center;cursor:pointer;transition:border-color .15s,box-shadow .15s}.device-template-tile:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}.device-template-tile>span{width:38px;height:38px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-4)}.device-template-tile>b{width:100%;font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.extension-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.extension-page-tile{min-width:0;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);overflow:hidden}.extension-page-tile .dashboard-thumb-live{height:92px;min-height:92px;border:0;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}.extension-page-tile>b{display:block;height:34px;line-height:34px;padding:0 8px;font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.extension-page-tile:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}.twin-scene-card-list{display:flex;flex-direction:column;gap:12px}.twin-scene-card{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);overflow:hidden}.twin-scene-card .sc-scene{height:145px}.twin-scene-card .sc-scene:after{height:72px;bottom:7px}.twin-scene-card .sc-scene i{transform:scale(.7);transform-origin:left bottom}.twin-scene-card>div:last-child{height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 12px}.twin-scene-card b{font-size:var(--ant-font-size-sm);font-weight:600}.twin-scene-card small{font-size:var(--ant-font-size-sm);color:var(--ant-color-success);background:var(--ant-color-bg-layout);padding:4px 8px}.knowledge-group+.knowledge-group{margin-top:20px}.knowledge-item-list{display:flex;flex-direction:column;gap:8px}.knowledge-item{min-height:55px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:grid;grid-template-columns:38px minmax(0,1fr);align-items:center;gap:12px;padding:8px}.knowledge-type{width:34px;height:34px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size)}.knowledge-type.link{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}.knowledge-item>div{min-width:0;display:flex;flex-direction:column;gap:4px}.knowledge-item b{font-size:var(--ant-font-size-sm);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.knowledge-item small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.knowledge-item:hover{border-color:var(--ant-color-primary-bg)}.device-template-empty{min-height:150px;display:grid;place-items:center;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
+
+.capability-preview-modal{width:min(1040px,94vw);max-width:94vw}.capability-preview-modal>.modal-body{min-height:520px;padding:20px}.capability-assets-preview,.capability-knowledge-preview{display:grid;grid-template-columns:1.2fr .8fr;gap:16px}.capability-assets-preview>section,.capability-knowledge-preview>section{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:16px}.capability-assets-preview h3,.capability-knowledge-preview h3{margin:0 0 12px;font-size:var(--ant-font-size)}.capability-full-tree{min-height:380px;background:var(--ant-color-bg-layout);padding:24px}.capability-full-tree>b{display:block;width:180px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:12px 12px;margin-bottom:20px}.capability-full-tree>div{margin:0 0 12px 36px;border-left:1px solid var(--ant-color-primary);padding-left:24px;position:relative}.capability-full-tree>div:before{content:"";position:absolute;left:0;top:13px;width:20px;border-top:1px solid var(--ant-color-primary)}.capability-full-tree span{display:block;width:170px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:8px 8px}.capability-full-tree small{display:block;margin:4px 0 0 12px;color:var(--ant-color-text-secondary)}.capability-preview-tabs{height:44px;display:flex;border-bottom:1px solid var(--ant-color-border);margin-bottom:16px}.capability-preview-tabs button{height:44px;border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);padding:0 20px;color:var(--ant-color-text-secondary)}.capability-preview-tabs button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.capability-preview-tabs b{font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);padding:4px 8px;border-radius:var(--ant-border-radius);margin-left:4px}.capability-dashboard-grid,.capability-twin-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.capability-dashboard-grid article,.capability-twin-grid article{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.capability-dashboard-grid .dashboard-thumb-live{height:230px!important;min-height:230px;background:var(--ant-color-bg-layout)}.capability-dashboard-grid article>div:last-child,.capability-twin-grid article>div:last-child{padding:12px 12px;display:flex;flex-direction:column;gap:4px}.capability-dashboard-grid article span,.capability-twin-grid article span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.sc-scene{height:230px;position:relative;overflow:hidden;background:linear-gradient(#edf5f7,#d9e5e8);perspective:500px}.sc-scene:after{content:"";position:absolute;left:8%;right:8%;bottom:18px;height:105px;background:linear-gradient(160deg,#b7cbd1,#eef4f5);transform:rotateX(58deg);border:1px solid #9fb7bf}.sc-scene i{position:absolute;z-index:2;width:75px;height:62px;background:#779aa5;border:1px solid #63828c;box-shadow:9px -9px #aec4ca}.sc-scene i:nth-child(1){left:20%;bottom:68px}.sc-scene i:nth-child(2){left:43%;bottom:86px;background:#7daeb9}.sc-scene i:nth-child(3){left:62%;bottom:57px;width:56px;height:76px}.sc-scene i:nth-child(4){left:76%;bottom:96px;width:40px;height:45px;background:#51a7c6}.sc-scene>span{position:absolute;z-index:3;right:12px;top:10px;background:#ffffffdd;color:#477783;padding:4px 8px;font-size:10px}.capability-knowledge-preview>section>div{height:58px;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;gap:12px}.capability-knowledge-preview>section>div>svg{font-size:var(--ant-font-size-heading-4);color:var(--ant-color-primary)}.capability-knowledge-preview>section>div>span:last-child{display:flex;flex-direction:column;gap:4px}.capability-knowledge-preview small{color:var(--ant-color-text-secondary)}.file-type{width:38px;height:27px;display:grid!important;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.capability-menu-full{display:grid;grid-template-columns:230px 1fr;min-height:500px;border:1px solid var(--ant-color-border)}.capability-menu-full aside{background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);padding:12px}.capability-menu-full aside>b{display:block;padding:12px 8px;border-bottom:1px solid var(--ant-color-border)}.capability-menu-full aside>div{display:flex;flex-direction:column}.capability-menu-full aside strong{padding:12px 8px;font-weight:600}.capability-menu-full aside span{padding:8px 24px;color:var(--ant-color-text-secondary)}.capability-menu-full aside>div:first-of-type strong{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.capability-menu-full>section{background:var(--ant-color-bg-layout)}.menu-screen-title{height:48px;background:var(--ant-color-bg-container);border-bottom:1px solid var(--ant-color-border);padding:16px 20px;font-weight:600}.menu-screen-canvas{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:24px}.menu-screen-canvas i{height:170px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);box-shadow:none}.menu-screen-canvas i:nth-child(2){box-shadow:none}.menu-screen-canvas i:nth-child(3){box-shadow:none}.menu-screen-canvas i:nth-child(4){box-shadow:none}
+
+.domain-import-page{max-width:980px}.domain-import-layout{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding:8px}.domain-import-drop{min-height:310px;border:1px dashed var(--ant-color-primary-bg);background:var(--ant-color-bg-layout);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:28px;cursor:pointer}.domain-import-drop:hover,.domain-import-drop.selected{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.domain-import-drop>svg{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-primary)}.domain-import-drop>b{font-size:var(--ant-font-size)}.domain-import-drop>span{max-width:320px;color:var(--ant-color-text-secondary);line-height:1.7}.domain-import-form{display:flex;flex-direction:column;gap:16px}.domain-import-form .field input,.domain-import-form .field select,.domain-import-form .field textarea{width:100%}.domain-package-check{margin:20px 8px 0;padding:16px;background:var(--ant-color-bg-layout);display:flex;align-items:center;gap:8px}.domain-package-check>b{margin-right:12px}.domain-package-check>span{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px 8px;color:var(--ant-color-text-secondary)}.domain-import-actions{display:flex;justify-content:flex-end;margin:16px 0 4px}.domain-maintain-modal{width:min(720px,92vw)}.domain-maintain-note{margin-top:20px;padding:12px 16px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);color:var(--ant-color-text-secondary)}.capability-package-modal{width:min(660px,92vw)}.capability-package-modal>.modal-body>div{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}.capability-package-modal>.modal-body>div>span{min-height:70px;border:1px solid var(--ant-color-border);padding:12px;display:grid;grid-template-columns:28px 1fr;grid-template-rows:auto auto;align-items:center}.capability-package-modal svg{grid-row:1/3;color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-4)}.capability-package-modal small{color:var(--ant-color-text-secondary)}
+@media(max-width:1320px){.domain-showcase-grid{grid-template-columns:1fr 1fr}.domain-showcase-intro{grid-template-columns:130px 1fr}.domain-showcase-total{grid-column:1/-1;border-top:1px solid var(--ant-color-border);padding-top:12px}.domain-capability-grid{grid-template-columns:1fr 1fr}.domain-capability-card{height:390px;min-height:0}.domain-capability-body.capability-empty-slot{min-height:0}.domain-capability-mini-tree>span:nth-last-child(n+4){display:none}}
+@media(max-width:900px){.domain-showcase-grid,.domain-capability-grid,.capability-assets-preview,.capability-knowledge-preview,.domain-import-layout{grid-template-columns:1fr}.domain-showcase-intro{grid-template-columns:1fr}.domain-showcase-intro>p{padding:0;border:0}.domain-capability-head{grid-template-columns:62px 1fr}.domain-capability-code{width:62px;height:62px}.domain-capability-actions{grid-column:1/-1;justify-content:flex-end}.domain-capability-summary{grid-template-columns:1fr 1fr;height:auto}.domain-capability-summary span{min-height:55px;border-bottom:1px solid var(--ant-color-border)}.domain-capability-summary span:nth-child(2n){border-right:0}.capability-dashboard-grid,.capability-twin-grid{grid-template-columns:1fr}}
+.capability-dashboard-grid article span,.capability-twin-grid article span{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;line-height:1.55}
+@media(max-width:600px){.domain-showcase-filter{height:auto;flex-wrap:wrap}.domain-showcase-filter label,.domain-showcase-filter select{width:100%;min-width:0}.domain-showcase-filter>span{width:100%;text-align:right}.domain-capability-head{grid-template-columns:1fr;padding:16px}.domain-capability-code{width:auto;height:38px;justify-self:start;padding:0 12px}.domain-capability-actions{width:100%;flex-direction:column}.domain-capability-actions .button{width:100%;margin:0}.domain-capability-card>header{grid-template-columns:38px 1fr}.domain-capability-card>header>strong{grid-column:2}.capability-mini-tree>span:nth-of-type(n+2){display:none}.capability-menu-full{grid-template-columns:1fr}.capability-menu-full>section{display:none}.capability-package-modal>.modal-body>div{grid-template-columns:1fr}.domain-package-check{align-items:flex-start;flex-wrap:wrap}.capability-preview-modal>.modal-body{padding:12px}}
+
+/* 领域扩展仪表板管理:用领域下拉框替代分类树。 */
+.domain-dashboard-manager{display:flex;flex-direction:column;gap:12px}.dashboard-manager-panel{min-height:620px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.dashboard-manager-toolbar{height:54px;border-bottom:1px solid var(--ant-color-border);display:grid;grid-template-columns:300px 1fr auto;align-items:center}.dashboard-manager-toolbar>label{height:100%;display:flex;align-items:center;gap:12px;padding:0 12px;border-right:1px solid var(--ant-color-border)}.dashboard-manager-toolbar>label span{font-weight:600;white-space:nowrap}.dashboard-manager-toolbar select{height:32px;min-width:190px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 28px 0 8px}.dashboard-manager-toolbar>strong{padding-left:20px;font-size:var(--ant-font-size)}.dashboard-manager-actions{display:flex;align-items:center;gap:8px;padding-right:12px}.dashboard-manager-actions .button{margin:0;min-width:72px}.dashboard-manager-search{width:220px;height:32px;border:1px solid var(--ant-color-border);display:flex!important;align-items:center;gap:8px;padding:0 8px!important}.dashboard-manager-search input{width:100%;height:28px;border:0;outline:0}.dashboard-manager-grid{display:grid;grid-template-columns:repeat(2,minmax(280px,354px));gap:24px;padding:20px 24px;align-content:start}.dashboard-manager-grid article{height:124px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:16px 44px 12px 16px;position:relative}.dashboard-manager-grid h3{margin:0 0 12px;font-size:var(--ant-font-size);font-weight:600}.dashboard-manager-grid p{margin:0 0 8px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.dashboard-card-more{position:absolute;right:8px;top:10px;width:30px;height:30px;border:0;background:transparent;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-heading-4)}.dashboard-card-more:hover{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
+@media(max-width:1100px){.dashboard-manager-toolbar{height:auto;grid-template-columns:1fr}.dashboard-manager-toolbar>label{height:50px;border-right:0;border-bottom:1px solid var(--ant-color-border)}.dashboard-manager-toolbar>strong{height:44px;display:flex;align-items:center;padding-left:12px}.dashboard-manager-actions{padding:0 12px 12px}.dashboard-manager-grid{grid-template-columns:1fr 1fr}}@media(max-width:720px){.dashboard-manager-actions{flex-wrap:wrap}.dashboard-manager-search{width:100%}.dashboard-manager-grid{grid-template-columns:1fr;padding:12px}}
+
+/* 仪表板资源页视觉密度:宽屏四列,等宽卡片,不保留分类树的空白。 */
+.dashboard-manager-panel{min-height:calc(100vh - 154px);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}.dashboard-manager-toolbar{height:56px;grid-template-columns:286px 1fr;background:var(--ant-color-bg-container)}.dashboard-manager-toolbar>label{padding:0 16px;gap:12px}.dashboard-manager-toolbar>label span{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.dashboard-manager-toolbar select{height:32px;min-width:176px;border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);color:var(--ant-color-text-secondary)}.dashboard-manager-actions{justify-self:end;gap:8px;padding-right:12px}.dashboard-manager-actions .button{height:32px;min-width:72px;padding:0 12px;border-radius:var(--ant-border-radius);font-weight:600}.dashboard-manager-search{width:210px;height:32px;background:var(--ant-color-bg-container);border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);color:var(--ant-color-text-secondary)}.dashboard-manager-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 20px;padding:20px 20px;align-items:start}.dashboard-manager-grid article{width:auto;height:124px;border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);padding:16px 44px 12px 16px;box-shadow:none;transition:border-color .15s,box-shadow .15s}.dashboard-manager-grid article:hover{border-color:var(--ant-color-primary-hover);box-shadow:none}.dashboard-manager-grid h3{margin:0 0 12px;font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-manager-grid p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.dashboard-card-more{right:8px;top:9px;transform:rotate(90deg)}.dashboard-card-more:hover{background:transparent;color:var(--ant-color-primary)}
+@media(max-width:1550px){.dashboard-manager-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
+@media(max-width:1120px){.dashboard-manager-toolbar{height:auto;grid-template-columns:1fr}.dashboard-manager-toolbar>label{height:50px;border-right:0;border-bottom:1px solid var(--ant-color-border)}.dashboard-manager-actions{justify-self:stretch;padding:0 12px 12px}.dashboard-manager-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:720px){.dashboard-manager-actions{flex-wrap:wrap}.dashboard-manager-search{width:100%}.dashboard-manager-grid{grid-template-columns:1fr;padding:12px}}
diff --git a/src-vue/styles/site-workspace.css b/src-vue/styles/site-workspace.css
new file mode 100644 (file)
index 0000000..03eba25
--- /dev/null
@@ -0,0 +1,73 @@
+/* Station hierarchy and five-step configuration: company-style flat workspaces. */
+.station-application-settings{display:flex;flex-direction:column;gap:16px}
+.station-application-basic,.station-application-menu{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}
+.station-application-basic>header,.station-application-menu>header{display:flex;align-items:center;justify-content:space-between;min-height:52px;padding:12px 20px;border-bottom:1px solid var(--ant-color-border);gap:16px}
+.station-application-settings h2{font-size:var(--ant-font-size);font-weight:600;margin:0;color:var(--ant-color-text-base)}
+.station-application-settings h2 small{font-size:var(--ant-font-size-sm);font-weight:400;color:var(--ant-color-text-secondary);margin-left:12px}
+.station-application-fields{display:grid;grid-template-columns:minmax(240px,1.2fr) minmax(280px,1.6fr) minmax(180px,.75fr);gap:32px;padding:20px;align-items:center}
+.station-application-fields>label,.station-application-icon{display:flex;align-items:center;gap:16px;min-width:0}
+.station-application-fields>label>span,.station-application-icon>span{flex-shrink:0;min-width:56px;white-space:nowrap;color:var(--ant-color-text-secondary)}
+.station-application-fields i:not(.station-application-scheme i),.station-menu-edit-fields>label>span>i{color:var(--ant-color-error);font-style:normal}
+.station-application-name input{width:100%;min-width:0}
+.station-application-settings input:not([type=file]),.station-application-settings select,.station-menu-edit-fields input{font:inherit;height:32px;min-width:0;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:4px 12px;background:var(--ant-color-bg-container)}
+.station-icon-control{display:flex;align-items:center;gap:12px;min-width:0}
+.station-icon-image{flex-shrink:0;width:40px;height:40px;border:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-3)}
+.station-icon-image img{width:100%;height:100%;object-fit:contain}
+.station-icon-upload{font-size:var(--ant-font-size);color:var(--ant-color-primary);cursor:pointer;white-space:nowrap;flex-shrink:0}
+.station-icon-upload.disabled{opacity:.5;cursor:default}
+.station-icon-file{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}
+.station-icon-control>.link{border:0;background:none;white-space:nowrap;font-size:var(--ant-font-size-sm);padding:0}
+.station-application-scheme>div{display:flex;align-items:center;gap:12px;flex:1;min-width:0}
+.station-application-scheme>div>i{width:20px;height:20px;flex-shrink:0;border:1px solid color-mix(in srgb,var(--ant-color-border) 7%,transparent)}
+.station-application-scheme select{width:100%}
+.station-application-menu-actions{display:flex;align-items:center;gap:8px;white-space:nowrap}
+.station-application-menu-scroll{height:calc(100vh - 515px);min-height:240px;overflow:auto}
+.station-application-menu-table{table-layout:fixed;width:100%;min-width:680px}
+.station-application-menu-table thead{position:sticky;top:0;z-index:1}
+.station-application-menu-table th:first-child{width:40%}.station-application-menu-table th:nth-child(2){width:30%}.station-application-menu-table th:nth-child(3){width:18%}.station-application-menu-table th:last-child{width:120px}
+.station-application-menu-table td{height:48px;padding:8px 16px;font-size:var(--ant-font-size)}.station-application-menu-table th{font-size:var(--ant-font-size);font-weight:600}
+.station-application-menu-table tr.station-menu-group-row td{background:var(--ant-color-bg-layout);color:var(--ant-color-text-base)}
+.station-menu-name{display:flex;align-items:center;gap:8px;min-width:0}
+.station-menu-name>span:not(.station-menu-child-guide){white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.station-menu-name small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);flex-shrink:0;font-weight:400}
+.station-menu-disclosure{flex-shrink:0;border:0;background:none;width:16px;padding:0;color:var(--ant-color-text-secondary);cursor:pointer}
+.station-menu-child-guide{width:16px;flex-shrink:0;align-self:stretch;position:relative}
+.station-menu-child-guide:before{content:"";position:absolute;left:3px;top:3px;width:8px;height:9px;border-left:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border)}
+.station-menu-association{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ant-color-text-secondary)}
+.station-application-settings .station-menu-row-actions .ant-btn{color:var(--ant-color-primary)}.station-application-settings .station-menu-row-actions .ant-btn:disabled{color:var(--ant-color-text-disabled)}
+.station-application-menu-note{padding:12px 20px;display:flex;justify-content:space-between;gap:16px;border-top:1px solid var(--ant-color-border);font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
+.station-menu-edit-fields{display:grid;gap:24px;padding-top:12px}.station-menu-edit-fields>label{display:grid;grid-template-columns:80px minmax(0,1fr);gap:16px;align-items:center}.station-menu-edit-fields>label>span{text-align:right;color:var(--ant-color-text-secondary)}.station-menu-edit-fields input[readonly]{background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary)}
+.station-application-preview{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);min-height:calc(100vh - 150px)}
+.station-application-preview>header{display:flex;align-items:center;gap:12px;border-top:3px solid var(--application-color);border-bottom:1px solid var(--ant-color-border);padding:16px;font-size:var(--ant-font-size)}
+.station-application-preview>header img{height:36px;width:36px;object-fit:contain}.station-application-preview>header>.anticon{font-size:var(--ant-font-size-heading-3);color:var(--application-color)}.station-application-preview strong{font-weight:600}
+.station-application-preview nav{padding:8px 0}.station-application-preview nav>button{display:flex;align-items:center;justify-content:space-between;width:100%;border:0;background:var(--ant-color-bg-container);padding:16px 20px;font:inherit;text-align:left;color:var(--ant-color-text-base);cursor:pointer}
+.station-application-preview nav>button.group{background:var(--ant-color-bg-layout);font-size:var(--ant-font-size)}.station-application-preview nav>button.page.active{background:color-mix(in srgb,var(--application-color) 14%,var(--ant-color-bg-container));color:var(--application-color);border-left:3px solid var(--application-color)}
+.station-menu-preview-current{display:flex;flex-direction:column;gap:12px;border-top:1px solid var(--ant-color-border);padding:20px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-menu-preview-current b{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}
+@media(max-width:1400px){.station-application-fields{gap:16px;grid-template-columns:minmax(210px,1.1fr) minmax(220px,1.2fr) minmax(150px,.75fr)}.station-icon-file{display:none}.station-application-menu-note>span:last-child{display:none}}
+@media(max-width:1050px){.station-application-fields{grid-template-columns:1fr;gap:20px}.station-application-fields>label>span,.station-application-icon>span{width:70px}.station-application-name input{max-width:450px}.station-application-scheme>div{max-width:260px}}
+.station-menu-configuration .station-menu-editor-table th:first-child{width:32%;white-space:nowrap}.station-menu-configuration .station-menu-editor-table th:nth-child(2){width:28%}.station-menu-configuration .station-menu-editor-table th:nth-child(3){width:20%}.station-menu-configuration .station-menu-editor-table th:last-child{width:110px;white-space:nowrap}.station-menu-configuration .station-menu-editor-table th,.station-menu-configuration .station-menu-editor-table td{font-size:var(--ant-font-size)}
+@media(max-width:1400px){.station-menu-configuration .station-menu-live-preview{display:block;grid-column:1/-1;border-left:0;border-top:1px solid var(--ant-color-border);max-height:220px;overflow:auto}.station-menu-configuration .station-menu-live-preview nav{display:flex;flex-wrap:wrap}.station-menu-configuration .station-menu-live-preview nav>div{min-width:150px}}
+.station-page-selection .station-module-grid{max-height:440px;overflow:auto}.station-page-selection .station-module-grid>section>div{grid-template-columns:repeat(2,minmax(0,1fr))}.station-page-selection .station-module-grid>section>div>label{font-size:var(--ant-font-size-sm)}
+.station-hierarchy-workspace .station-hierarchy-table{min-width:1100px;table-layout:fixed}
+.station-hierarchy-workspace .station-hierarchy-table th:first-child,.station-hierarchy-workspace .station-hierarchy-table td:first-child{width:28%;overflow:hidden}
+.station-hierarchy-workspace .station-hierarchy-table th:nth-child(2){width:14%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(3){width:17%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(4){width:10%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(5){width:6%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(6){width:15%}.station-hierarchy-workspace .station-hierarchy-table th:last-child,.station-hierarchy-workspace .station-hierarchy-table td:last-child{width:130px}
+.station-hierarchy-workspace .station-name-cell>.link{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.station-hierarchy-workspace,.station-page-selection,.station-menu-configuration{font-size:var(--ant-font-size);color:var(--ant-color-text-base);min-width:0}
+.station-list-panel,.station-template-panel,.station-page-module-panel,.station-menu-panel,.station-menu-app-info{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}
+.station-list-panel{min-height:calc(100vh - 260px);display:flex;flex-direction:column}
+.station-hierarchy-note{display:flex;align-items:center;gap:12px;padding:16px;border-bottom:1px solid var(--ant-color-border);color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}
+.station-hierarchy-note>.anticon{color:var(--ant-color-primary)}.station-hierarchy-note>span:last-child{margin-left:auto;white-space:nowrap}
+.station-list-toolbar{display:flex;gap:12px;align-items:center;padding:16px}.station-list-toolbar>div{margin-left:auto;display:flex;gap:8px}
+.station-search{display:flex;align-items:center;gap:8px;height:32px;width:260px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary)}.station-search input{border:0!important;outline:0;width:100%;height:28px;background:transparent}
+.station-list-toolbar select,.station-basic-fields select,.station-basic-fields input,.station-basic-fields textarea,.station-create-template>select,.station-template-filter select,.station-menu-app-info input,.station-menu-editor-table input,.station-menu-editor-table select{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:4px 12px;min-height:32px;font:inherit}
+.station-list-toolbar select{width:190px}.station-table-scroll{overflow:auto;flex:1}.station-hierarchy-table th:first-child{width:29%}.station-hierarchy-table th:nth-child(3){width:19%}.station-hierarchy-table td{height:54px}.station-hierarchy-table th{white-space:nowrap}.station-name-cell{display:flex;align-items:center;gap:8px;min-width:210px}.station-name-cell>button:first-child:not(.link){border:0;background:none;width:18px;padding:0}.station-tree-spacer{width:18px}.station-name-cell small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);border:1px solid var(--ant-color-border);padding:0 4px}.station-template-name{display:block}.station-pending-template{display:block;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.station-table-actions{display:flex;align-items:center;white-space:nowrap}
+.station-drawer-actions{display:flex;justify-content:flex-end;gap:8px}.station-drawer-note{padding:12px;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);margin:0 0 24px}.station-basic-fields{display:grid;gap:20px}.station-basic-fields>label{display:grid;grid-template-columns:100px 1fr;gap:12px;align-items:start}.station-basic-fields>label>span{padding-top:8px;text-align:right}.station-basic-fields i{color:var(--ant-color-error);font-style:normal}.station-basic-fields textarea{resize:vertical}.station-create-template{margin-top:32px;border-top:1px solid var(--ant-color-border);padding-top:16px}.station-create-template h3{font-size:var(--ant-font-size);font-weight:600}.station-template-modes{display:flex;gap:24px;margin-bottom:16px}.station-template-modes input{accent-color:var(--ant-color-primary)}.station-create-template>select{width:100%}.station-selected-template{padding:16px;border:1px solid var(--ant-color-primary-bg);background:var(--ant-color-bg-layout);margin-top:16px}.station-selected-template p,.station-template-footnote{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.7}.station-selected-template span{color:var(--ant-color-text-secondary)}
+.station-config-context{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding:12px 16px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}.station-config-context>div{display:flex;align-items:center;gap:12px}.station-context-divider{height:16px;border-left:1px solid var(--ant-color-border)}.station-config-context>div:last-child>span{color:var(--ant-color-text-secondary)}.station-config-context .ant-tag{margin:0}
+.station-template-panel{border-top:3px solid var(--ant-color-primary);margin-bottom:16px}.station-template-panel>header,.station-page-module-panel>header,.station-menu-panel>header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 20px;border-bottom:1px solid var(--ant-color-border)}.station-template-panel h2,.station-page-module-panel h2,.station-menu-panel h2{font-size:var(--ant-font-size);font-weight:600;margin:0 0 8px}.station-template-panel>header p,.station-page-module-panel>header p,.station-menu-panel>header p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-template-panel>header>div:last-child,.station-menu-panel>header>div:last-child{display:flex;gap:8px;white-space:nowrap}
+.station-template-filter{display:flex;align-items:center;gap:12px;padding:16px 20px;color:var(--ant-color-text-secondary)}.station-template-filter>span:last-child{margin-left:auto;font-size:var(--ant-font-size-sm)}.station-template-filter select{width:200px}.station-template-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:0 20px 20px;max-height:300px;overflow:auto}.station-template-cards>button{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);text-align:left;padding:16px;min-width:0}.station-template-cards>button.selected{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.station-template-cards>button>div{display:flex;align-items:center;gap:8px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-template-cards .anticon{margin-left:auto;color:var(--ant-color-primary)}.station-template-cards h3{font-size:var(--ant-font-size);font-weight:600;margin:16px 0 8px}.station-template-cards p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);height:34px;overflow:hidden;margin:0 0 16px}.station-template-cards footer{display:flex;gap:24px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-template-cards footer b{color:var(--ant-color-text-base)}.station-template-empty{padding:28px;text-align:center;color:var(--ant-color-text-secondary)}
+.station-overview-layout .form-section-nav{display:none}.station-overview-layout .company-form-page{display:block}.station-overview-layout .single-column-form{max-width:none}.station-overview-layout .company-form-page>.single-column-form{width:100%}.station-opsflow-blank{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);min-height:calc(100vh - 342px)}
+.station-unified-assets .asset-editor-workbench{height:calc(100vh - 390px);min-height:430px;grid-template-columns:220px minmax(0,1fr)}.station-unified-assets .asset-tree-canvas{min-height:0;height:calc(100% - 52px);overflow:auto}.station-unified-assets .asset-template-panel{max-height:calc(100vh - 575px);min-height:220px;overflow:auto}.station-unified-assets .asset-editor-toolbar{gap:8px;padding:0 8px}.station-unified-assets .asset-editor-toolbar>div{gap:4px}.station-unified-assets .asset-tree-search{width:160px}.station-unified-assets .asset-node-list{max-height:calc(100vh - 445px);overflow:auto}
+.station-page-selection{display:flex;flex-direction:column;gap:16px}.station-selection-summary{display:flex;gap:28px;border-bottom:1px solid var(--ant-color-border);padding:12px 20px;color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}.station-selection-summary>span:last-child{margin-left:auto;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-module-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;padding:20px}.station-module-grid>section{border:1px solid var(--ant-color-border)}.station-module-grid>section>header{display:flex;justify-content:space-between;padding:12px;background:var(--ant-color-bg-layout);border-bottom:1px solid var(--ant-color-border)}.station-module-grid>section>header span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-module-grid label{display:flex;gap:8px;align-items:center}.station-module-grid>section>div{display:grid;gap:12px;padding:16px;max-height:210px;overflow:auto}.station-page-selection input[type=checkbox]{accent-color:var(--ant-color-primary)}
+.station-dashboard-selection{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:20px}.station-dashboard-selection article,.station-twin-selection article{border:1px solid var(--ant-color-border);min-width:0}.station-dashboard-image{display:block;width:100%;height:145px;padding:0;border:0;overflow:hidden;background:var(--ant-color-bg-layout);cursor:pointer;text-align:left;font:inherit}.station-dashboard-image:focus-visible{outline:2px solid var(--ant-color-primary);outline-offset:-2px}.station-dashboard-selection article.selected{border-color:var(--ant-color-primary)}.station-dashboard-selection article.selected footer{background:var(--ant-color-bg-layout)}.station-dashboard-image>div{height:100%}.station-dashboard-selection footer,.station-twin-selection footer{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-top:1px solid var(--ant-color-border)}.station-dashboard-selection footer label,.station-twin-selection footer label{display:flex;align-items:center;gap:8px}.station-twin-selection{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:20px}.station-twin-selection small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.station-scene-thumb{height:145px;background:#edf4f6;position:relative;overflow:hidden}.station-scene-thumb>span{position:absolute;right:10px;top:10px;font-size:11px;color:#65909e}.station-scene-thumb>div{position:absolute;left:10%;right:10%;bottom:20px;height:45px;background:#d0e0e5;transform:skew(-20deg);border:1px solid #a5c3ce}.station-scene-thumb i{position:absolute;width:45px;height:43px;bottom:54px;background:#7ba8b6;border:1px solid #608a97;box-shadow:5px -5px #bfd2d9;z-index:1}.station-scene-thumb i:nth-of-type(1){left:18%}.station-scene-thumb i:nth-of-type(2){left:40%;bottom:70px}.station-scene-thumb i:nth-of-type(3){left:64%}.station-scene-thumb i:nth-of-type(4){left:80%;width:22px;height:28px;bottom:78px}
+.station-menu-app-info{padding:16px 20px;margin-bottom:16px;display:flex;align-items:center;gap:20px}.station-menu-app-info label{display:flex;align-items:center;gap:16px}.station-menu-app-info input{width:300px}.station-menu-app-info p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin:0}.station-menu-workbench{display:grid;grid-template-columns:250px minmax(0,1fr) 220px;min-height:calc(100vh - 480px)}.station-menu-workbench>aside{background:var(--ant-color-bg-layout)}.station-menu-source{border-right:1px solid var(--ant-color-border);padding:16px}.station-menu-workbench h3{font-size:var(--ant-font-size);font-weight:600;margin:0 0 16px}.station-menu-workbench h3 small{color:var(--ant-color-text-secondary);float:right}.station-menu-source .station-search{width:100%;margin-bottom:16px}.station-menu-source>div{max-height:calc(100vh - 585px);min-height:230px;overflow:auto}.station-menu-source>div>button{display:flex;align-items:center;justify-content:space-between;width:100%;padding:12px 8px;margin-bottom:8px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);text-align:left}.station-menu-source b{font-size:var(--ant-font-size-sm);font-weight:400}.station-menu-source small{display:block;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);margin-top:4px}.station-menu-source .anticon{color:var(--ant-color-primary)}.station-menu-source button:disabled{opacity:.5;cursor:default}.station-menu-editor-table{overflow:auto;max-height:calc(100vh - 445px)}.station-menu-editor-table .data-table{min-width:520px}.station-menu-editor-table td{padding:12px}.station-menu-editor-table input,.station-menu-editor-table select{width:100%}.station-menu-row-actions{display:flex;gap:0}.station-menu-row-actions .ant-btn{width:28px}.station-menu-live-preview{border-left:1px solid var(--ant-color-border);padding:16px 0}.station-menu-live-preview h3{padding:0 16px}.station-menu-live-preview>b{display:block;padding:16px;background:var(--ant-color-bg-layout);font-size:var(--ant-font-size);font-weight:600}.station-menu-live-preview nav>div{padding:16px;font-size:var(--ant-font-size)}.station-menu-live-preview nav>.group{background:var(--ant-color-bg-layout)}.station-menu-live-preview nav>.page{color:var(--ant-color-primary)}.station-menu-live-preview .anticon{float:right}.station-menu-empty{padding:60px 16px;color:var(--ant-color-text-secondary);text-align:center}.station-warning{color:var(--ant-color-error);background:var(--ant-color-bg-layout);padding:16px}.station-replace-confirm{max-width:520px}
+@media(max-width:1400px){.station-menu-workbench{grid-template-columns:220px minmax(0,1fr)}.station-menu-live-preview{display:none}.station-hierarchy-note>span:last-child{display:none}.station-module-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.station-dashboard-selection,.station-twin-selection{grid-template-columns:repeat(3,minmax(0,1fr))}.station-unified-assets .asset-tree-search{width:120px}.station-template-cards{grid-template-columns:repeat(2,minmax(0,1fr))}}
diff --git a/src-vue/styles/styles.less b/src-vue/styles/styles.less
new file mode 100644 (file)
index 0000000..d9668db
--- /dev/null
@@ -0,0 +1,51 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: var(--ant-font-family);
+  background: var(--ant-color-bg-layout);
+  color: var(--ant-color-text-base);
+}
+
+#root {
+  min-height: 100vh;
+}
+
+/* ===== 整体布局 ===== */
+// Header 高度(含 8px 顶部渐变条),同时也是内容区的避让量
+@header-height: 56px;
+
+.page {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  padding-top: @header-height;
+
+  // 左右两栏:宽度全部交给 flex,不写死 calc
+  .body {
+    display: flex;
+    flex: 1 1 auto;
+    align-items: stretch;
+  }
+
+  // 左栏宽度由 SideMenu 自身决定(展开 316px / 折叠 72px),随整页一起滚动
+  .left-menu {
+    flex: 0 0 auto;
+  }
+
+  /* 注:#ededed 暂无对应 antd token(colorBgLayout 已被覆盖为 #fafafa,与此值不同) */
+  .main {
+    // flex:1 自动占满剩余宽度;min-width:0 允许收缩,避免内部宽内容把布局撑破
+    flex: 1 1 auto;
+    min-width: 0;
+    padding: 8px 0 0;
+    background: #ededed;
+
+    .page-header {
+      padding: 0 24px;
+    }
+  }
+}
diff --git a/src-vue/styles/theme-overrides.css b/src-vue/styles/theme-overrides.css
new file mode 100644 (file)
index 0000000..6ac07e3
--- /dev/null
@@ -0,0 +1,47 @@
+/* ============================================================
+ * overrides.css — L0 设计覆盖
+ *
+ * 只放 antd token 表达不了的东西:
+ *   - 渐变(token 只能设单一颜色)
+ *   - 多层阴影
+ *   - 同一组件内不同部位要不同值(如表头 vs 表体)
+ *
+ * 能用 token 表达的一律写进 src/theme.json。
+ * 消费品牌值一律用 var(--ant-*),不要硬编码。
+ * ============================================================ */
+
+/* ── Menu:hover / 选中的渐变背景 ──
+ * 载体在 src/components/menu/index.less(需覆盖 inline 与 portal 两种 DOM),此处不重复。
+ */
+
+/* ── Select 下拉 ──
+ * 注意:Select 没有 dropdownPadding / boxShadow 组件 token,
+ * 写 --ant-select-dropdown-padding / --ant-select-box-shadow 是空操作,必须用真实声明。
+ */
+.ant-select-dropdown {
+  padding: 8px 0;
+  border: 1px solid var(--ant-color-primary);
+  box-shadow: 0 2px 8px 0 rgba(50, 50, 51, 0.2);
+}
+
+.ant-select.ant-select-focused {
+  box-shadow: 0 2px 8px 0 rgba(50, 50, 51, 0.2);
+}
+
+/* 选项 hover 渐变(渐变 token 表达不了) */
+.ant-select-dropdown .ant-select-item:hover,
+.ant-select-dropdown .ant-select-item-option-active {
+  background: linear-gradient(
+    180deg,
+    rgba(0, 140, 214, 0.10) 0%,
+    rgba(0, 110, 255, 0.10) 100%
+  ), #FFF !important;
+}
+
+/* ── Table:表头与表体内边距不同 ──
+ * cellPaddingBlock 作用于所有单元格(已设 8),表头要 12,
+ * token 区分不了表头/表体,只能用变量。
+ */
+.ant-table .ant-table-thead > tr > th {
+  --ant-table-cell-padding-block: 12px;
+}
diff --git a/src-vue/styles/theme.json b/src-vue/styles/theme.json
new file mode 100644 (file)
index 0000000..c92f030
--- /dev/null
@@ -0,0 +1,62 @@
+{
+  "token": {
+    "colorPrimary": "#008cd6",
+    "colorInfo": "#008cd6",
+    "colorSuccess": "#00cc66",
+    "colorError": "#f55d3e",
+    "colorWarning": "#ffa400",
+    "colorTextBase": "#323233",
+    "colorBgLayout": "#fafafa",
+    "fontSize": 16,
+    "borderRadius": 4,
+    "fontFamily": "'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
+    "fontFamilyCode": "\"Roboto\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", sans-serif"
+  },
+  "components": {
+    "Table": {
+      "borderColor": "rgb(225,225,225)",
+      "headerColor": "rgb(50,50,51)",
+      "footerColor": "rgb(50,50,51)",
+      "rowHoverBg": "rgb(230,242,254)",
+      "rowSelectedBg": "rgb(230,242,254)",
+      "cellPaddingBlock": 8
+    },
+    "Button": {
+      "defaultActiveBorderColor": "rgb(0,140,214)",
+      "defaultActiveColor": "rgb(0,140,214)",
+      "defaultBorderColor": "rgb(200,200,200)",
+      "defaultColor": "rgb(137,137,138)",
+      "defaultHoverBorderColor": "rgb(137,137,138)",
+      "defaultHoverColor": "rgb(50,50,51)",
+      "defaultActiveBg": "rgb(229,243,252)",
+      "fontWeight": 600
+    },
+    "Switch": {
+      "handleSize": 12,
+      "trackHeight": 16,
+      "trackMinWidth": 24,
+      "trackPadding": 2,
+      "innerMaxMargin": 16
+    },
+    "Menu": {
+      "itemColor": "rgb(50,50,51)",
+      "itemHoverColor": "rgb(50,50,51)",
+      "subMenuItemBg": "rgb(255,255,255)",
+      "subMenuItemBorderRadius": 0,
+      "itemBorderRadius": 0
+    },
+    "Breadcrumb": {
+      "itemColor": "rgb(137,137,138)",
+      "lastItemColor": "rgb(137,137,138)",
+      "linkColor": "rgb(137,137,138)",
+      "linkHoverColor": "rgb(0,140,214)",
+      "separatorColor": "rgb(192,192,192)"
+    },
+    "Select": {
+      "optionActiveBg": "rgb(229,242,253)",
+      "optionSelectedBg": "rgb(229,242,253)",
+      "optionSelectedColor": "rgb(0,140,214)",
+      "optionPadding": "8px 12px"
+    }
+  }
+}
diff --git a/src-vue/styles/ui-layout-compat.css b/src-vue/styles/ui-layout-compat.css
new file mode 100644 (file)
index 0000000..c717ac4
--- /dev/null
@@ -0,0 +1,130 @@
+/* Layout-only compatibility for existing custom antd button surfaces.
+ * Preserves approved navigation, thumbnail and canvas sizing, including responsive rules.
+ * Branding and states remain owned by theme.json and ui-system.css. */
+.side-nav>button.station-ui-button.ant-btn{height:41px;border:0;text-align:left;padding:0 16px;display:flex;align-items:center;gap:12px;}
+.side-nav>button.station-ui-button.ant-btn b{width:18px;text-align:center;}
+.side-nav>button.station-ui-button.ant-btn em{width:21px;height:21px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;}
+.steps button.station-ui-button.ant-btn{border:0;display:flex;align-items:center;position:relative;text-align:left;padding:0 20px;}
+.steps button.station-ui-button.ant-btn>i{width:29px;height:29px;border-radius:50%;border:1px solid var(--ant-color-border);display:grid;margin-right:12px;}
+.steps button.station-ui-button.ant-btn span{display:flex;flex-direction:column;gap:4px;}
+.steps button.station-ui-button.ant-btn>b{position:absolute;right:0;}
+.view-switch button.station-ui-button.ant-btn{width:31px;height:29px;border:1px solid var(--ant-color-border);}
+.link.station-ui-button.ant-btn{border:0;padding:0;}
+.trash.station-ui-button.ant-btn{border:0;}
+.pagination button.station-ui-button.ant-btn{width:28px;height:28px;border:0;}
+.tabs button.station-ui-button.ant-btn{padding:0 20px;border:0;}
+.mu-grid button.station-ui-button.ant-btn{border:0;padding:0;}
+.menu-tree>button.station-ui-button.ant-btn{border:1px dashed var(--ant-color-border);width:100%;height:34px;}
+.menu-preview button.station-ui-button.ant-btn{border:0;text-align:left;padding:12px;}
+.confirm-grid button.station-ui-button.ant-btn{position:static;border:0;margin:0;}
+.text-button.station-ui-button.ant-btn{border:0;margin-top:12px;}
+.preview-toolbar button.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);height:29px;padding:0 12px;}
+.cube-side button.station-ui-button.ant-btn{height:37px;border:0;text-align:left;padding-left:12px;}
+.cube-grid h3 button.station-ui-button.ant-btn{border:0;}
+.inspector>nav button.station-ui-button.ant-btn{flex:1;border:0;}
+.code-body header button.station-ui-button.ant-btn{border:0;}
+.modal>header button.station-ui-button.ant-btn{border:0;}
+.history-list button.station-ui-button.ant-btn{border:0;}
+.module-switch>button.station-ui-button.ant-btn{height:47px;border:1px solid var(--ant-color-border);display:flex;align-items:center;text-align:left;padding:0 8px;gap:8px;}
+.module-switch>button.station-ui-button.ant-btn>b{width:23px;height:23px;display:grid;}
+.module-switch>button.station-ui-button.ant-btn>span{display:flex;flex-direction:column;gap:4px;}
+.domain-grid footer button.station-ui-button.ant-btn{border:0;}
+.domain-grid footer button.station-ui-button.ant-btn:last-child{border:1px solid var(--ant-color-primary-bg);padding:4px 8px;}
+.domain-page-list footer button.station-ui-button.ant-btn{border:0;padding:0;margin-right:12px;}
+.knowledge-list button.station-ui-button.ant-btn{border:0;}
+.model-list>button.station-ui-button.ant-btn{width:100%;min-height:56px;border:0;border-radius:var(--ant-border-radius);display:flex;align-items:center;padding:8px 8px;text-align:left;position:relative;}
+.model-list>button.station-ui-button.ant-btn>i{width:25px;}
+.model-list>button.station-ui-button.ant-btn>span{display:flex;flex:1;flex-direction:column;gap:4px;}
+.model-list>button.station-ui-button.ant-btn>strong{margin-right:24px;}
+.model-list>button.station-ui-button.ant-btn>u{position:absolute;right:9px;}
+.wordbuddy-card>footer button.station-ui-button.ant-btn{border:0;}
+.ai-fab.station-ui-button.ant-btn{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;border:0;display:grid;}
+.ai-dialog .ai-close.station-ui-button.ant-btn{border:0;line-height:1;width:28px;height:28px;border-radius:var(--ant-border-radius);}
+.data-table.editable .trash.station-ui-button.ant-btn{border:0;line-height:1;}
+.data-table.editable .asset-eye-btn.station-ui-button.ant-btn{border:0;line-height:1;padding:0;display:inline-flex;align-items:center;}
+.cube-actions button.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:26px;padding:0 8px;}
+.menu-row .trash.station-ui-button.ant-btn{border:1px solid var(--ant-color-error-bg);border-radius:var(--ant-border-radius);height:30px;width:34px;}
+.menu-preview .menu-section-title.station-ui-button.ant-btn{margin-bottom:8px;}
+.style-pick.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);padding:8px;display:flex;flex-direction:column;gap:8px;text-align:center;}
+.module-switch>button.station-ui-button.ant-btn{height:50px;border:0;border-radius:0;padding:0 20px;gap:12px;}
+.module-switch>button.station-ui-button.ant-btn>b{width:18px;height:auto;}
+.module-switch>button.station-ui-button.ant-btn>span{display:block;}
+.module-switch>button.station-ui-button.ant-btn small{display:none;}
+.side-nav>button.station-ui-button.ant-btn{height:44px;padding:0 20px;gap:12px;}
+.side-nav>button.station-ui-button.ant-btn em{width:20px;height:20px;}
+.steps button.station-ui-button.ant-btn{padding:0 20px;}
+.steps button.station-ui-button.ant-btn>i{width:26px;height:26px;}
+.company-status-tabs button.station-ui-button.ant-btn{height:48px;min-width:120px;padding:0 24px;border:0;}
+.company-status-tabs button.station-ui-button.ant-btn span{margin-right:8px;}
+.company-status-tabs button.station-ui-button.ant-btn b{display:inline-grid;min-width:24px;height:20px;padding:0 8px;border-radius:var(--ant-border-radius);}
+.pagination button.station-ui-button.ant-btn{height:32px;border:1px solid var(--ant-color-border);}
+.pagination button.station-ui-button.ant-btn{width:34px;}
+.domain-select .text-button.station-ui-button.ant-btn{margin:0;}
+@media (max-width:900px){.company-status-tabs button.station-ui-button.ant-btn{min-width:100px;}}
+.menu-section button.station-ui-button.ant-btn{width:100%;border:0;text-align:left;}
+.menu-section-title.station-ui-button.ant-btn{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;line-height:56px;}
+.menu-section-items button.station-ui-button.ant-btn{height:50px;padding:0 20px 0 40px;display:flex;align-items:center;line-height:50px;}
+.extension-third-menu>button.station-ui-button.ant-btn{height:50px;padding:0 20px 0 60px;display:flex;align-items:center;line-height:50px;border:0;width:100%;text-align:left;}
+.company-side-footer>button.station-ui-button.ant-btn{height:26px;padding:0;border:0;display:flex;align-items:center;justify-content:space-between;text-align:left;}
+.company-model-menu button.station-ui-button.ant-btn{width:100%;height:32px;border:0;display:flex;justify-content:space-between;align-items:center;padding:0 8px;}
+.site-edit-tabs>button.station-ui-button.ant-btn{height:64px;flex:1;min-width:0;border:0;display:flex;align-items:center;gap:12px;padding:0 20px;text-align:left;}
+.site-edit-tabs>button.station-ui-button.ant-btn i{width:26px;height:26px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;}
+.asset-config-tabs>button.station-ui-button.ant-btn{height:65px;min-width:210px;padding:12px 4px 8px;border:0;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:4px;}
+.asset-editor-title>.text-button.station-ui-button.ant-btn{margin:0;padding-right:20px;}
+.asset-batch-entry.station-ui-button.ant-btn{width:100%;height:54px;border:0;text-align:left;padding:0 16px;}
+.asset-template-panel>button.station-ui-button.ant-btn{min-height:58px;border:1px solid var(--ant-color-border);display:grid;text-align:left;padding:8px;column-gap:8px;}
+.asset-template-panel>button.station-ui-button.ant-btn>span{align-self:center;}
+.node-drawer-tabs button.station-ui-button.ant-btn{border:0;padding:0 20px;}
+@media (max-width:1100px){.asset-config-tabs>button.station-ui-button.ant-btn{min-width:180px;}}
+.domain-resource-table .company-record-link.station-ui-button.ant-btn{display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-align:left;}
+.device-resource-table .company-record-link.station-ui-button.ant-btn{display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-align:left;}
+.resource-name-cell .company-record-link.station-ui-button.ant-btn{min-width:0;overflow:hidden;white-space:nowrap;text-align:left;}
+.resource-back .text-button.station-ui-button.ant-btn{margin:0;}
+.style-resource-table .company-record-link.station-ui-button.ant-btn{display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-align:left;}
+.view-toggle button.station-ui-button.ant-btn{border:0;height:28px;padding:0 12px;}
+.settings-domain-table .text-button.station-ui-button.ant-btn{margin:0;}
+.domain-nav-row>button.station-ui-button.ant-btn:not(.domain-nav-del):not(.domain-nav-ok){flex:1;min-width:0;text-align:left;height:41px;border:0;padding:0 16px;display:flex;align-items:center;white-space:nowrap;}
+.domain-nav-row>button.station-ui-button.ant-btn b{width:18px;margin-right:8px;flex:0 0 auto;}
+.domain-nav-del.station-ui-button.ant-btn{border:0;width:30px;line-height:1;flex:0 0 auto;}
+.domain-nav-ok.station-ui-button.ant-btn{border:0;width:30px;flex:0 0 auto;}
+.domain-nav-add.station-ui-button.ant-btn{height:34px;border:0;text-align:left;padding:0 16px;display:flex;align-items:center;width:100%;}
+.overview-tabs button.station-ui-button.ant-btn{padding:0 16px;}
+.dev-row-head.station-ui-button.ant-btn{width:100%;border:0;display:flex;align-items:center;gap:12px;padding:12px 12px;text-align:left;}
+.wise-header button.station-ui-button.ant-btn{border:0;padding:0;}
+.wise-menu-button.station-ui-button.ant-btn{flex:0 0 24px;width:24px;height:24px;margin-right:12px;display:grid;}
+.wise-product-switch.station-ui-button.ant-btn{flex:0 0 auto;height:48px;margin-left:12px;display:flex;align-items:center;white-space:nowrap;}
+.wise-header-actions>button.station-ui-button.ant-btn:not(.wise-avatar){flex:0 0 40px;width:40px;height:40px;display:grid;border-radius:0;}
+.wise-header-actions>button.station-ui-button.ant-btn>img{display:block;width:24px;height:24px;}
+.wise-avatar.station-ui-button.ant-btn{flex:0 0 24px;width:24px;height:24px;margin:0 8px;border-radius:50%!important;line-height:24px;text-align:center;}
+@media (max-width:1120px){.wise-avatar.station-ui-button.ant-btn{margin-right:8px;}}
+@media (max-width:700px){.wise-header-actions>button.station-ui-button.ant-btn:not(.wise-avatar){width:36px;flex-basis:36px;}}
+@media (max-width:560px){.wise-product-switch.station-ui-button.ant-btn{margin-left:4px;}}
+@media (max-width:560px){.wise-header-actions>button.station-ui-button.ant-btn:nth-child(1),.wise-header-actions>button.station-ui-button.ant-btn:nth-child(2){display:none;}}
+.asset-batch-entry.station-ui-button.ant-btn{height:72px;padding:0 20px;}
+.asset-template-panel>button.station-ui-button.ant-btn{min-height:50px;border:0;padding:8px 8px;}
+.asset-template-panel>button.station-ui-button.ant-btn:hover{border:0;}
+.asset-view-switch button.station-ui-button.ant-btn{width:38px;border:0;}
+.asset-node-card>button.station-ui-button.ant-btn{align-self:center;justify-self:end;width:19px;height:19px;border:0;border-radius:50%;padding:0;display:grid;}
+.asset-node-list>button.station-ui-button.ant-btn{height:43px;display:grid;align-items:center;text-align:left;border:0;padding:0 16px;}
+.asset-node-drawer .node-drawer-tabs button.station-ui-button.ant-btn{height:49px;padding:0 20px;}
+.asset-node-drawer .node-drawer-tabs button.station-ui-button.ant-btn:first-child{padding-left:0;margin-right:20px;}
+.node-image-row .text-button.station-ui-button.ant-btn{margin:4px 0 0;}
+.edge-component-table button.station-ui-button.ant-btn{width:28px;height:28px;border:0;}
+.binding-component-tabs button.station-ui-button.ant-btn{height:34px;padding:0 20px;border:0;}
+.domain-showcase-card>footer button.station-ui-button.ant-btn{height:30px;border:0;padding:0;}
+.domain-capability-back.station-ui-button.ant-btn{align-self:flex-start;border:0;padding:0;height:28px;}
+.domain-capability-card>footer button.station-ui-button.ant-btn{height:29px;border:1px solid var(--ant-color-border);padding:0 12px;}
+.domain-capability-card>footer button.station-ui-button.ant-btn.capability-edit-button{margin-right:auto;}
+.capability-preview-tabs button.station-ui-button.ant-btn{height:44px;border:0;padding:0 20px;}
+.dashboard-card-more.station-ui-button.ant-btn{position:absolute;right:8px;top:10px;width:30px;height:30px;border:0;}
+.dashboard-card-more.station-ui-button.ant-btn{right:8px;top:9px;}
+.station-icon-control>.link.station-ui-button.ant-btn{border:0;white-space:nowrap;padding:0;}
+.station-menu-disclosure.station-ui-button.ant-btn{flex-shrink:0;border:0;width:16px;padding:0;}
+.station-application-preview nav>button.station-ui-button.ant-btn{display:flex;align-items:center;justify-content:space-between;width:100%;border:0;padding:16px 20px;text-align:left;}
+.station-hierarchy-workspace .station-name-cell>.link.station-ui-button.ant-btn{white-space:nowrap;overflow:hidden;}
+.station-name-cell>button.station-ui-button.ant-btn:first-child:not(.link){border:0;width:18px;padding:0;}
+.station-template-cards>button.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);text-align:left;padding:16px;min-width:0;}
+.station-template-cards>button.station-ui-button.ant-btn>div{display:flex;align-items:center;gap:8px;}
+.station-dashboard-image.station-ui-button.ant-btn{display:block;width:100%;height:145px;padding:0;border:0;overflow:hidden;text-align:left;}
+.station-menu-source>div>button.station-ui-button.ant-btn{display:flex;align-items:center;justify-content:space-between;width:100%;padding:12px 8px;margin-bottom:8px;border:1px solid var(--ant-color-border);text-align:left;}
+.steps button.station-ui-button.ant-btn{height:100%;}
diff --git a/src-vue/styles/ui-system.css b/src-vue/styles/ui-system.css
new file mode 100644 (file)
index 0000000..9f82242
--- /dev/null
@@ -0,0 +1,92 @@
+/* Supplied Skill values are resolved in company-theme.tsx; no independent palette. */
+html,body,.ant-app{font-family:var(--ant-font-family);font-size:var(--ant-font-size);color:var(--ant-color-text-base);background:var(--ant-color-bg-layout)}
+.workspace{background:var(--ant-color-bg-layout);min-width:0}
+.page-heading{background:var(--ant-color-bg-layout)}
+.company-side-nav{font-family:var(--ant-font-family)}
+.company-side-nav .menu-section button{font-weight:400;font-size:var(--ant-font-size);border-radius:0}
+.company-side-nav .menu-section-title{font-weight:600!important}
+.company-side-nav .menu-section-items button.active{font-weight:400;background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}
+.company-side-nav .menu-section-items button:hover{background:var(--ant-table-row-hover-bg);color:var(--ant-color-primary)}
+.extension-menu-item,.extension-third-menu>div,.extension-third-menu>button{font-family:var(--ant-font-family);font-size:var(--ant-font-size);font-weight:400}
+.button.ant-btn{height:var(--ant-control-height);font-size:var(--ant-font-size);border-radius:var(--ant-border-radius);padding-inline:var(--ant-padding);margin-left:0;box-shadow:none}
+.button.ant-btn-primary,.btn-ai.ant-btn{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}
+.button.ant-btn-primary:not(:disabled):hover,.btn-ai.ant-btn:not(:disabled):hover{background:var(--ant-color-primary-hover);border-color:var(--ant-color-primary-hover)}
+.button.secondary.ant-btn{background:var(--ant-color-bg-container);color:var(--ant-button-default-color);border-color:var(--ant-button-default-border-color)}
+.button.secondary.ant-btn:not(:disabled):hover{color:var(--ant-button-default-hover-color);border-color:var(--ant-button-default-hover-border-color)}
+.button.ant-btn:disabled,.button.ant-btn-primary:disabled{background:var(--ant-color-bg-container-disabled);border-color:var(--ant-color-border);color:var(--ant-color-text-disabled);opacity:1}
+.station-ui-select{min-width:120px;height:var(--ant-control-height);font-size:var(--ant-font-size)}
+.field>.station-ui-select,.form-grid .station-ui-select,.station-basic-fields .station-ui-select,.station-create-template>.station-ui-select,.node-basic-grid .station-ui-select,.alarm-form-grid .station-ui-select{width:100%}
+.station-ui-input.ant-input{height:var(--ant-control-height);font-size:var(--ant-font-size);border-radius:var(--ant-border-radius);padding:var(--ant-padding-xxs) var(--ant-padding-xs)}
+.station-ui-input.ant-input:focus{border-color:var(--ant-color-primary);box-shadow:0 0 0 2px var(--ant-color-primary-bg)}
+.station-ui-input.ant-input:disabled{background:var(--ant-color-bg-container-disabled);color:var(--ant-color-text-disabled)}
+.search-field>.station-ui-input,.station-search>.station-ui-input,.dashboard-manager-search>.station-ui-input{border:0!important;box-shadow:none!important;background:transparent!important;min-width:0;flex:1}
+.station-ui-choice{flex-shrink:0;margin:0}
+.station-ui-table{border-collapse:initial;max-width:100%}
+.station-ui-table table{width:100%}
+.station-ui-table .ant-table-thead>tr>th{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-table-header-color);background:var(--ant-color-bg-layout);padding-block:var(--ant-padding-sm);border-color:var(--ant-table-border-color)}
+.station-ui-table .ant-table-tbody>tr>td{font-size:var(--ant-font-size);font-weight:400;color:var(--ant-color-text-base);padding-block:var(--ant-padding-xs);border-color:var(--ant-table-border-color)}
+.station-ui-table .ant-table-tbody>tr:hover>td,.station-ui-table .ant-table-tbody>tr>td.ant-table-cell-row-hover{background:var(--ant-table-row-hover-bg)}
+.station-ui-table input[type=checkbox]{appearance:auto;width:auto;height:auto;padding:0;border:0;background:none;margin:0;opacity:0}
+.station-ui-table .ant-checkbox-inner{border-radius:var(--ant-border-radius-sm)}
+.ant-tag.status,.ant-tag.tag-new{border-radius:var(--ant-border-radius);font-size:var(--ant-font-size-sm);line-height:var(--ant-line-height-sm);padding:var(--ant-padding-xxs) var(--ant-padding-xs)}
+.status.ok{color:var(--ant-color-success-text);background:var(--ant-color-success-bg);border-color:var(--ant-color-success-border)}
+.status.warn{color:var(--ant-color-warning-text);background:var(--ant-color-warning-bg);border-color:var(--ant-color-warning-border)}
+.status.bad{color:var(--ant-color-error-text);background:var(--ant-color-error-bg);border-color:var(--ant-color-error-border)}
+.status.muted{color:var(--ant-color-text-secondary);background:var(--ant-color-fill-quaternary);border-color:var(--ant-color-border)}
+.ant-drawer-title{font-size:var(--ant-font-size);font-weight:600}
+.ant-drawer-body{background:var(--ant-color-bg-container)}
+.ant-drawer-footer .station-drawer-actions,.ant-drawer-footer .drawer-actions{display:flex;flex-wrap:nowrap;justify-content:flex-end;gap:var(--ant-padding-xs)}
+.domain-capability-actions,.domain-capability-card>footer,.filter-actions,.station-application-menu-actions,.sticky-actions>div{display:flex;flex-wrap:nowrap;align-items:center;gap:var(--ant-padding-xs)}
+.domain-capability-card>footer{overflow-x:auto;flex-shrink:0}
+.domain-capability-card>footer .ant-btn{height:var(--ant-control-height);font-size:var(--ant-font-size);white-space:nowrap}
+.domain-capability-card,.station-page-module-panel,.station-list-panel,.station-application-basic,.station-application-menu{background:var(--ant-color-bg-container);border-color:var(--ant-color-border);border-radius:var(--ant-border-radius)}
+.domain-capability-card{overflow:hidden}
+.domain-capability-card>header h3,.station-application-settings h2{font-size:var(--ant-font-size);font-weight:600}
+.domain-capability-body{min-height:0;overflow:auto}
+.device-template-tile b{font-size:var(--ant-font-size-sm);font-weight:400;line-height:var(--ant-line-height-sm)}
+.station-dashboard-selection,.station-twin-selection{gap:var(--ant-padding);padding:var(--ant-padding)}
+.station-application-settings input:not([type=file]),.station-menu-edit-fields input{border-radius:var(--ant-border-radius);height:var(--ant-control-height)}
+.station-application-settings .station-menu-row-actions .ant-btn{color:var(--ant-color-primary)}
+.station-application-settings .station-menu-row-actions .ant-btn:disabled{color:var(--ant-color-text-disabled)}
+.station-hierarchy-workspace,.station-page-selection,.station-menu-configuration{font-size:var(--ant-font-size)}
+.station-application-fields{gap:var(--ant-padding-lg);padding:var(--ant-padding-lg)}
+.station-ui-table .station-menu-group-row>td{background:var(--ant-color-bg-layout)}
+.station-ui-table .station-menu-group-row:hover>td{background:var(--ant-table-row-hover-bg)}
+.station-opsflow-blank{background:var(--ant-color-bg-container)}
+@media(max-width:1200px){.domain-capability-actions{overflow:auto;max-width:100%}.station-application-fields{gap:var(--ant-padding)}}
+.panel.ant-card{border-radius:var(--ant-border-radius-lg)}
+.panel.ant-card>.ant-card-head{padding-inline:var(--ant-padding);min-height:48px}
+.panel.ant-card>.ant-card-head .ant-card-head-title{padding:var(--ant-padding-xs) 0;font-size:var(--ant-font-size);font-weight:600}
+.panel.ant-card>.ant-card-body{padding:var(--ant-padding-lg)}
+.company-list-panel.ant-card>.ant-card-body{padding:0 var(--ant-padding) var(--ant-padding-sm)}
+.field.ant-form-item{display:block;gap:initial;margin:0;min-width:0}
+.field.ant-form-item .ant-form-item-label{padding:0 0 var(--ant-padding-xs)}
+.field.ant-form-item .ant-form-item-label>label{height:auto;font-size:var(--ant-font-size);font-weight:400;color:var(--ant-color-text-base)}
+.field.ant-form-item .ant-form-item-extra{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
+.field.ant-form-item .ant-input:not(textarea){height:var(--ant-control-height)}
+.station-ui-tabs.company-status-tabs,.station-ui-tabs.node-drawer-tabs,.station-ui-tabs.asset-config-tabs,.station-ui-tabs.binding-component-tabs,.station-ui-tabs.tabs{display:block;height:auto;padding-top:0;gap:initial;border:0;margin:0;background:transparent}
+.station-ui-tabs>.ant-tabs-nav{margin:0 0 var(--ant-padding)}
+.station-ui-tabs>.ant-tabs-nav .ant-tabs-tab{padding:var(--ant-padding-sm) var(--ant-padding);font-size:var(--ant-font-size);font-weight:400}
+.station-ui-tabs>.ant-tabs-nav .ant-tabs-ink-bar{height:2px;background:var(--ant-color-primary)}
+.station-ui-tabs .ant-tabs-tab-btn{font-weight:400}
+.station-ui-tabs .ant-tabs-tab-btn b,.station-ui-tabs .ant-tabs-tab-btn i{margin-left:var(--ant-padding-xs);background:var(--ant-color-fill-secondary);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);font-style:normal;font-weight:600;padding:0 var(--ant-padding-xxs);border-radius:var(--ant-border-radius)}
+.station-ui-tabs .ant-tabs-tab-active .ant-tabs-tab-btn b{background:var(--ant-color-success-bg);color:var(--ant-color-success-text)}
+.station-ui-tabs .ant-tabs-content-holder{min-width:0}
+.station-ui-tabs .ant-tabs-content{background:var(--ant-color-bg-layout)}
+.node-config-page .field.ant-form-item .ant-form-item-row{display:flex;flex-flow:row nowrap;align-items:center}
+.node-config-page .field.ant-form-item .ant-form-item-label{flex:0 0 100px;padding:0 var(--ant-padding-xs) 0 0;text-align:right}
+.node-config-page .field.ant-form-item .ant-form-item-control{flex:1;min-width:0}
+.alarm-enable-switch.ant-switch{min-width:var(--ant-switch-track-min-width);height:var(--ant-switch-track-height);padding:0;border:0}
+.station-inline-actions,.station-modal-actions,.station-drawer-actions,.drawer-actions,.result-actions,.settings-actions{display:flex;flex-wrap:nowrap;align-items:center;gap:var(--ant-padding-xs)}
+.station-modal-actions{justify-content:flex-end}
+/* Ant Modal owns positioning, focus and chrome; old class names only identify business content. */
+.station-ui-modal.modal,.station-ui-modal.dlg-box{display:block;position:relative;inset:auto;margin-inline:auto;padding:0;max-width:calc(100vw - 32px);max-height:none;height:auto;background:none;border:0;border-radius:0;box-shadow:none;overflow:visible;z-index:auto;color:var(--ant-color-text-base);font-size:var(--ant-font-size)}
+.station-ui-modal .ant-modal-container{padding:var(--ant-padding-lg);border-radius:var(--ant-border-radius-lg)}
+.station-ui-modal .ant-modal-title{font-size:var(--ant-font-size-heading-4);font-weight:600}
+.station-ui-modal .ant-modal-body{font-size:var(--ant-font-size);color:var(--ant-color-text-base)}
+.station-ui-modal .modal-body{padding:0;max-height:70vh;overflow:auto}
+.station-ui-modal .ant-modal-footer{margin-top:var(--ant-padding-lg)}
+.station-ui-modal .station-modal-actions .ant-btn{margin:0}
+.station-ui-select .ant-select-selector{font-size:var(--ant-font-size)}
+.filterbar.company-filterbar{height:auto;min-height:48px;flex-wrap:nowrap}
+.filterbar.company-filterbar .station-ui-select{flex-shrink:0;min-width:164px}
diff --git a/src-vue/ui/UiActions.vue b/src-vue/ui/UiActions.vue
new file mode 100644 (file)
index 0000000..38786d4
--- /dev/null
@@ -0,0 +1,107 @@
+<script>
+import { computed, h, cloneVNode, useSlots, useAttrs, Fragment } from "vue";
+import {
+  Button as AntButton,
+  Dropdown as AntDropdown,
+  Menu as AntMenu,
+} from "ant-design-vue";
+import { EllipsisOutlined } from "@ant-design/icons-vue";
+import UiButton from "./UiButton.vue";
+import { elements, textOf, classOf } from "./parse.js";
+
+export default {
+  setup() {
+    const slots = useSlots();
+    const attrs = useAttrs();
+
+    const parts = computed(() => elements(slots.default ? slots.default() : []));
+
+    const isActionType = (v) =>
+      v.type === UiButton || v.type === AntButton || v.type === AntDropdown;
+    const businessAction = (v) => !!(v.type && v.type.uiPrimaryAction);
+    const actionable = (v) => isActionType(v) || businessAction(v);
+
+    const actions = computed(() => parts.value.filter(actionable));
+    const extras = computed(() => parts.value.filter((v) => !actionable(v)));
+
+    const isPrimary = (v) => {
+      const p = v.props || {};
+      const cn = classOf(v);
+      if (p.type === "primary") return true;
+      if (businessAction(v) && !p.secondary) return true;
+      if (
+        v.type === UiButton &&
+        /button|btn-ai/.test(cn) &&
+        !p.secondary &&
+        !/secondary/.test(cn)
+      )
+        return true;
+      return false;
+    };
+
+    const primaryIndex = computed(() => {
+      let idx = -1;
+      actions.value.forEach((a, i) => {
+        if (isPrimary(a)) idx = i;
+      });
+      return idx;
+    });
+
+    const rendered = computed(() =>
+      actions.value.map((action, index) => {
+        if (index === primaryIndex.value || !isPrimary(action)) return action;
+        const custom = action.type === UiButton || businessAction(action);
+        return cloneVNode(action, custom ? { secondary: true } : { type: "default" });
+      })
+    );
+
+    const showMore = computed(() => actions.value.length > 3);
+
+    function triggerAction(vnode, e) {
+      if (vnode.props && vnode.props.onClick) vnode.props.onClick(e);
+    }
+
+    function classAttr() {
+      const c = attrs.class;
+      return typeof c === "string" ? c : Array.isArray(c) ? c.join(" ") : "";
+    }
+
+    return () => {
+      const children = [];
+      if (showMore.value) {
+        rendered.value.slice(0, 2).forEach((a) => children.push(a));
+        const menuItems = rendered.value.slice(2).map((a, i) =>
+          h(
+            AntMenu.Item,
+            {
+              key: String(i),
+              disabled: !!(a.props && a.props.disabled),
+              onClick: (e) => triggerAction(a, e.domEvent || e),
+            },
+            () => textOf(a.props ? a.props.children : a.children)
+          )
+        );
+        children.push(
+          h(
+            AntDropdown,
+            { trigger: ["click"] },
+            {
+              overlay: () => h(AntMenu, {}, { default: () => menuItems }),
+              default: () =>
+                h(
+                  AntButton,
+                  { "aria-label": "更多操作", icon: h(EllipsisOutlined) },
+                  () => "更多"
+                ),
+            }
+          )
+        );
+      } else {
+        rendered.value.forEach((a) => children.push(a));
+      }
+      extras.value.forEach((ex) => children.push(ex));
+      return h("div", { class: classAttr() }, children);
+    };
+  },
+};
+</script>
diff --git a/src-vue/ui/UiButton.vue b/src-vue/ui/UiButton.vue
new file mode 100644 (file)
index 0000000..49ca81b
--- /dev/null
@@ -0,0 +1,54 @@
+<script setup>
+import { computed, useAttrs, defineOptions } from "vue";
+
+defineOptions({ inheritAttrs: false });
+defineProps({
+  type: { type: String, default: "button" },
+  secondary: { type: Boolean, default: false },
+});
+
+const attrs = useAttrs();
+const classStr = computed(() => {
+  const c = attrs.class;
+  return typeof c === "string" ? c : Array.isArray(c) ? c.join(" ") : "";
+});
+
+const isSwitch = computed(() => attrs.role === "switch");
+const isAction = computed(() => /(^|\s)(button|btn-ai|ai-send|sso-button)(\s|$)/.test(classStr.value));
+const isSecondary = computed(() => /(^|\s)secondary(\s|$)/.test(classStr.value));
+const isLink = computed(() => /(^|\s)(link|text-button|domain-capability-back)(\s|$)/.test(classStr.value));
+const isDanger = computed(() => /(^|\s)(danger|trash)(\s|$)/.test(classStr.value));
+const btnType = computed(() =>
+  isLink.value ? "link" : isAction.value ? (isSecondary.value ? "default" : "primary") : "text"
+);
+
+const passthrough = computed(() => ({
+  onClick: attrs.onClick,
+  disabled: attrs.disabled,
+  title: attrs.title,
+  "aria-label": attrs["aria-label"],
+  "aria-expanded": attrs["aria-expanded"],
+  role: attrs.role,
+}));
+</script>
+
+<template>
+  <a-switch
+    v-if="isSwitch"
+    :checked="attrs['aria-checked'] === true"
+    :disabled="attrs.disabled"
+    :aria-label="attrs['aria-label']"
+    :class="attrs.class"
+    @change="() => attrs.onClick && attrs.onClick()"
+  />
+  <a-button
+    v-else
+    :html-type="type || 'button'"
+    :type="btnType"
+    :danger="isDanger"
+    class="station-ui-button"
+    v-bind="passthrough"
+  >
+    <slot />
+  </a-button>
+</template>
diff --git a/src-vue/ui/UiInput.vue b/src-vue/ui/UiInput.vue
new file mode 100644 (file)
index 0000000..9aabece
--- /dev/null
@@ -0,0 +1,88 @@
+<script setup>
+import { computed, useAttrs, defineOptions } from "vue";
+
+defineOptions({ inheritAttrs: false });
+const props = defineProps({
+  type: { type: String, default: "text" },
+  value: { type: [String, Number], default: "" },
+  checked: { type: Boolean, default: false },
+  defaultValue: { type: [String, Number, Boolean], default: undefined },
+  disabled: { type: Boolean, default: false },
+  id: { type: String, default: undefined },
+  name: { type: String, default: undefined },
+  placeholder: { type: String, default: "" },
+  maxLength: { type: [String, Number], default: undefined },
+  rows: { type: [String, Number], default: undefined },
+  readonly: { type: Boolean, default: false },
+  autofocus: { type: Boolean, default: false },
+  "aria-label": { type: String, default: undefined },
+  textarea: { type: Boolean, default: false },
+});
+
+const attrs = useAttrs();
+const emit = defineEmits(["change"]);
+
+const kind = computed(() =>
+  props.textarea ? "textarea" : props.type === "checkbox" ? "checkbox" : props.type === "radio" ? "radio" : "text"
+);
+
+const shared = computed(() => ({
+  disabled: props.disabled,
+  id: props.id,
+  name: props.name,
+  "aria-label": props["aria-label"],
+  onClick: attrs.onClick,
+  onBlur: attrs.onBlur,
+  onKeydown: attrs.onKeydown,
+  onFocus: attrs.onFocus,
+  onInput: attrs.onInput,
+}));
+
+const passthrough = computed(() => ({
+  ...shared.value,
+  class: attrs.class,
+}));
+
+function onInput(e) {
+  emit("change", e);
+}
+</script>
+
+<template>
+  <a-checkbox v-if="kind === 'checkbox'" :checked="checked" :value="value" v-bind="shared" @change="onInput">
+    <slot />
+  </a-checkbox>
+  <a-radio v-else-if="kind === 'radio'" :checked="checked" :value="value" v-bind="shared" @change="onInput">
+    <slot />
+  </a-radio>
+  <a-textarea
+    v-else-if="kind === 'textarea'"
+    :value="value"
+    :placeholder="placeholder"
+    :rows="rows"
+    :maxlength="maxLength"
+    :disabled="disabled"
+    :readonly="readonly"
+    :autofocus="autofocus"
+    v-bind="passthrough"
+    @change="onInput"
+  >
+    <slot />
+  </a-textarea>
+  <a-input
+    v-else
+    :type="type"
+    :value="value"
+    :placeholder="placeholder"
+    :maxlength="maxLength"
+    :disabled="disabled"
+    :readonly="readonly"
+    :autofocus="autofocus"
+    v-bind="passthrough"
+    @change="onInput"
+  >
+    <template v-if="$slots.prefix" #prefix>
+      <slot name="prefix" />
+    </template>
+  </a-input>
+</template>
diff --git a/src-vue/ui/UiOverlay.vue b/src-vue/ui/UiOverlay.vue
new file mode 100644 (file)
index 0000000..b1a30a7
--- /dev/null
@@ -0,0 +1,96 @@
+<script>
+import { h, useSlots, useAttrs, Fragment } from "vue";
+import { Modal as AntModal } from "ant-design-vue";
+import UiActions from "./UiActions.vue";
+import { elements, textOf, classOf } from "./parse.js";
+
+export default {
+  setup() {
+    const slots = useSlots();
+    const attrs = useAttrs();
+
+    return () => {
+      const children = slots.default ? slots.default() : [];
+      const all = elements(children);
+      const inner = all.find((c) => /\b(modal|dlg-box)\b/.test(classOf(c)));
+      if (!inner) return null;
+
+      const innerChildren = elements(inner.props?.children);
+      const header = innerChildren.find((p) => p.type === "header");
+      const heading =
+        (header ? elements(header.props.children) : []).find((p) => p.type === "h2") ||
+        innerChildren.find((p) => p.type === "h2");
+      const footer = innerChildren.find(
+        (p) =>
+          p.type === "footer" ||
+          (p.props && p.props.class && String(p.props.class).split(" ").includes("dlg-actions"))
+      );
+
+      const name = classOf(inner);
+      const styleW = inner.props?.style?.width;
+      const width =
+        styleW && styleW !== "auto"
+          ? styleW
+          : /preview|prompt/.test(name)
+          ? 1200
+          : /editor|batch-create/.test(name)
+          ? 960
+          : /maintain|import|package/.test(name)
+          ? 720
+          : 480;
+
+      let close;
+      if (attrs.onMouseDown) close = attrs.onMouseDown;
+      else if (attrs.onClick) close = attrs.onClick;
+      else {
+        const hdrBtn = header
+          ? elements(header.props.children).find(
+              (p) => p.props && p.props.onClick && !p.props.disabled
+            )
+          : null;
+        if (hdrBtn) close = hdrBtn.props.onClick;
+        else if (footer) {
+          const cancel = elements(footer.props.children).find(
+            (p) =>
+              textOf(p.props ? p.props.children : p.children).includes("取消") &&
+              p.props &&
+              !p.props.disabled
+          );
+          if (cancel) close = cancel.props.onClick;
+        }
+      }
+
+      const extras = all.filter((c) => !/\b(modal|dlg-box)\b/.test(classOf(c)));
+
+      let body = innerChildren.filter((p) => p !== header && p !== footer);
+      if (heading) body = body.filter((p) => p !== heading);
+
+      const closeFn = (e) => {
+        if (close) close(e);
+      };
+
+      const modalProps = {
+        open: true,
+        centered: true,
+        title: heading ? heading.props.children : undefined,
+        width,
+        class: name + " station-ui-modal",
+        closable: !!close,
+        keyboard: !!close,
+        maskClosable: !!close,
+        onCancel: closeFn,
+      };
+
+      const modalSlots = { default: () => body };
+      if (footer) {
+        modalSlots.footer = () =>
+          h(UiActions, { class: "station-modal-actions" }, () => footer.props.children);
+      } else {
+        modalProps.footer = null;
+      }
+
+      return h(Fragment, {}, [h(AntModal, modalProps, modalSlots), ...extras]);
+    };
+  },
+};
+</script>
diff --git a/src-vue/ui/UiSelect.vue b/src-vue/ui/UiSelect.vue
new file mode 100644 (file)
index 0000000..65624ae
--- /dev/null
@@ -0,0 +1,63 @@
+<script setup>
+import { computed, useSlots, useAttrs, defineOptions } from "vue";
+import { elements, textOf } from "./parse.js";
+
+defineOptions({ inheritAttrs: false });
+
+const props = defineProps({
+  value: { type: [String, Number], default: undefined },
+  defaultValue: { type: [String, Number], default: undefined },
+  disabled: { type: Boolean, default: false },
+  id: { type: String, default: undefined },
+  name: { type: String, default: undefined },
+  "aria-label": { type: String, default: undefined },
+});
+
+const slots = useSlots();
+const attrs = useAttrs();
+const emit = defineEmits(["change"]);
+
+// Port of React UiSelect: parse <option> children into antd Select options.
+const options = computed(() => {
+  const opts = elements(slots.default ? slots.default() : []).filter(
+    (o) => o.type === "option"
+  );
+  return opts.map((option) => ({
+    value: String(option.props?.value ?? textOf(option.children)),
+    label: option.children,
+    disabled: !!option.props?.disabled,
+  }));
+});
+
+const modelValue = computed(() =>
+  props.value === undefined ? undefined : String(props.value)
+);
+
+const defaultVal = computed(() => {
+  if (props.defaultValue === undefined)
+    return options.value.find((o) => !o.disabled)?.value;
+  return String(props.defaultValue);
+});
+
+function onChange(selected) {
+  emit("change", {
+    target: { value: selected, name: props.name },
+    currentTarget: { value: selected, name: props.name },
+  });
+}
+</script>
+
+<template>
+  <a-select
+    :id="id"
+    :name="name"
+    :disabled="disabled"
+    :aria-label="props['aria-label']"
+    :value="modelValue"
+    :default-value="defaultVal"
+    :options="options"
+    :class="(typeof attrs.class === 'string' ? attrs.class : '') + ' station-ui-select'"
+    :style="attrs.style"
+    @change="onChange"
+  />
+</template>
diff --git a/src-vue/ui/UiTabbedRegion.vue b/src-vue/ui/UiTabbedRegion.vue
new file mode 100644 (file)
index 0000000..79c503a
--- /dev/null
@@ -0,0 +1,52 @@
+<script>
+import { computed, h, useSlots } from "vue";
+import { Tabs as AntTabs } from "ant-design-vue";
+import { elements, classOf } from "./parse.js";
+
+export default {
+  setup() {
+    const slots = useSlots();
+
+    const buttons = computed(() => {
+      const nav = slots.navigation ? slots.navigation() : [];
+      return elements(nav);
+    });
+    const navClass = computed(() => {
+      const nav = slots.navigation ? slots.navigation() : [];
+      const first = elements(nav)[0];
+      return first ? classOf(first) : "";
+    });
+    const active = computed(
+      () =>
+        buttons.value.find(
+          (b) =>
+            b.props &&
+            (b.props["aria-selected"] === true ||
+              /(^|\s)active(\s|$)/.test(classOf(b)))
+        ) ||
+        buttons.value[0]
+    );
+    const activeKey = computed(() => String(active.value?.key ?? 0));
+
+    function onChange(key) {
+      const idx = buttons.value.findIndex(
+        (item, index) => String(item.key ?? index) === key
+      );
+      const btn = buttons.value[idx];
+      if (btn && btn.props && btn.props.onClick) btn.props.onClick(undefined);
+    }
+
+    return () =>
+      h(AntTabs, {
+        class: navClass.value + " station-ui-tabs",
+        activeKey: activeKey.value,
+        onChange,
+        items: buttons.value.map((button, index) => ({
+          key: String(button.key ?? index),
+          label: button.props?.children,
+          children: () => (slots.default ? slots.default() : null),
+        })),
+      });
+  },
+};
+</script>
diff --git a/src-vue/ui/UiTable.vue b/src-vue/ui/UiTable.vue
new file mode 100644 (file)
index 0000000..fcdb491
--- /dev/null
@@ -0,0 +1,88 @@
+<script setup>
+import { computed, useAttrs, defineOptions } from "vue";
+import { elements, classOf } from "./parse.js";
+
+defineOptions({ inheritAttrs: false });
+
+const props = defineProps({
+  className: { type: String, default: "" },
+});
+
+const slots = defineSlots();
+const attrs = useAttrs();
+
+// Port of React UiTable: translate <thead>/<tbody> markup into antd Table columns/data.
+const sections = computed(() => elements(slots.default ? slots.default() : []));
+const head = computed(() =>
+  sections.value.find((s) => s.type === "thead")
+);
+const body = computed(() =>
+  sections.value.find((s) => s.type === "tbody")
+);
+const headerRow = computed(() => {
+  const rows = elements(head.value?.props?.children);
+  return rows[0];
+});
+const headers = computed(() => elements(headerRow.value?.props?.children));
+const cols = computed(() => {
+  const cg = sections.value.find((s) => s.type === "colgroup");
+  return elements(cg?.props?.children);
+});
+
+const data = computed(() =>
+  elements(body.value?.props?.children).map((row, index) => ({
+    key: String(row.props?.key ?? index),
+    cells: elements(row.props?.children),
+    props: row.props || {},
+  }))
+);
+
+const columns = computed(() =>
+  headers.value.map((header, index) => {
+    const hprops = header.props || {};
+    const hrest = { ...hprops };
+    delete hrest.children;
+    const colEl = cols.value[index];
+    const width =
+      colEl?.props?.style?.width ||
+      colEl?.props?.width ||
+      hprops.style?.width;
+    return {
+      key: String(index),
+      title: hprops.children,
+      width,
+      customRender: ({ index: ri }) => {
+        const cell = data.value[ri]?.cells[index];
+        return cell?.props?.children;
+      },
+      onHeaderCell: () => hrest,
+      onCell: (record) => {
+        const cell = record.cells[index];
+        if (!cell) return { colSpan: 0 };
+        const crest = { ...(cell.props || {}) };
+        delete crest.children;
+        return crest;
+      },
+    };
+  })
+);
+
+function customRow(record) {
+  const rrest = { ...(record.props || {}) };
+  delete rrest.children;
+  return rrest;
+}
+</script>
+
+<template>
+  <a-table
+    :class="(typeof attrs.class === 'string' ? attrs.class : '') + ' station-ui-table'"
+    :style="attrs.style"
+    :columns="columns"
+    :data-source="data"
+    :pagination="false"
+    :row-key="(record) => record.key"
+    :custom-row="customRow"
+    :locale="{ emptyText: '暂无数据' }"
+  />
+</template>
diff --git a/src-vue/ui/parse.js b/src-vue/ui/parse.js
new file mode 100644 (file)
index 0000000..0495c41
--- /dev/null
@@ -0,0 +1,57 @@
+import { Fragment, Comment, Text } from "vue";
+
+// Mirror React ui-system.tsx's Children/elements/textOf helpers for Vue vnodes.
+// React used className; Vue vnodes carry `class`. We normalise both.
+
+function isVNode(n) {
+  return !!n && typeof n === "object" && n !== null && "type" in n;
+}
+
+function classOf(vnode) {
+  const c = vnode && vnode.props ? vnode.props.class : undefined;
+  if (c == null) return "";
+  if (typeof c === "string") return c;
+  if (Array.isArray(c)) return c.filter(Boolean).join(" ");
+  if (typeof c === "object") return Object.keys(c).filter((k) => c[k]).join(" ");
+  return String(c);
+}
+
+function elements(children) {
+  if (children == null) return [];
+  const arr = Array.isArray(children) ? children : [children];
+  const out = [];
+  for (const child of arr) {
+    if (!isVNode(child)) continue;
+    if (child.type === Fragment) {
+      out.push(...elements(child.children));
+    } else if (child.type === Comment) {
+      // skip comments
+    } else {
+      out.push(child);
+    }
+  }
+  return out;
+}
+
+function textOf(children) {
+  if (children == null) return "";
+  const arr = Array.isArray(children) ? children : [children];
+  let s = "";
+  for (const child of arr) {
+    if (child == null) continue;
+    if (typeof child === "string" || typeof child === "number") {
+      s += String(child);
+      continue;
+    }
+    if (isVNode(child)) {
+      if (child.type === Text) {
+        s += String(child.children ?? "");
+        continue;
+      }
+      s += textOf(child.children);
+    }
+  }
+  return s;
+}
+
+export { isVNode, classOf, elements, textOf };
diff --git a/vite.local.config.js b/vite.local.config.js
new file mode 100644 (file)
index 0000000..7ebf772
--- /dev/null
@@ -0,0 +1,48 @@
+import vue from "@vitejs/plugin-vue";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+
+// Self-contained local UI configuration for the Vue 3 rewrite of StationBuilder.
+// Same application base path and business components as the React source; a client-only
+// local preview without production hosting metadata or the RSC development runtime.
+export default defineConfig({
+  // Vue 使用独立的 root 与 HTML 入口,避免与 React 的 index.html 耦合。
+  root: "src-vue",
+  base: "/t/stationbuilder-vue",
+  build: {
+    // root 已切到 src-vue,outDir 用绝对路径固定到仓库根 dist-vue,
+    // 与 stationbuilder-vue.service 的 vite preview 保持一致。
+    outDir: fileURLToPath(new URL("./dist-vue", import.meta.url)),
+    emptyOutDir: true,
+  },
+  resolve: {
+    alias: {
+      "@/assets": fileURLToPath(new URL("./src/assets", import.meta.url)),
+      "@": fileURLToPath(new URL("./src-vue", import.meta.url)),
+    },
+  },
+  server: {
+    host: "127.0.0.1",
+    allowedHosts: true,
+    hmr: { overlay: false },
+    proxy: {
+      "/api/db": {
+        target: "http://127.0.0.1:37823",
+        changeOrigin: true,
+        rewrite: (path) => path.replace(/^\/api\/db/, "/api"),
+      },
+      "/api/chat": {
+        target: "http://127.0.0.1:37822",
+        changeOrigin: true,
+      },
+    },
+  },
+  plugins: [
+    vue({
+      // StationBuilder serves its images from /public at the app base path, referenced as
+      // absolute /t/stationbuilder/... URLs. Disable SFC asset URL rewriting so these stay
+      // as runtime public URLs instead of being bundled as module imports.
+      template: { transformAssetUrls: false },
+    }),
+  ],
+});