next-env.d.ts
tsconfig.tsbuildinfo
/dist/
+/dist-vue/
+/src-vue/dist-vue/
/.wrangler/
/.sites-runtime/
/outputs/
"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",
"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",
},
"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",
"react": "19.2.6",
"react-dom": "19.2.6",
"sharp": "0.34.5",
+ "vue": "^3.5.43",
"xlsx": "^0.18.5"
},
"devDependencies": {
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<template>
+ <div class="content dashboard-page">
+ <img src="/t/stationbuilder/dashboard-preview.jpg" alt="仪表盘示意" loading="lazy" />
+ </div>
+</template>
--- /dev/null
+<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>
--- /dev/null
+// 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 };
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+//带有菜单的按钮
+.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
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+// 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);
+ }
+ }
+ }
+ }
+}
--- /dev/null
+// 底部分页栏
+@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);
+ }
+ }
+ }
+}
--- /dev/null
+// 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
--- /dev/null
+<!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>
--- /dev/null
+// 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;
+}
--- /dev/null
+// 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" },
+];
--- /dev/null
+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");
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<template>
+ <div class="content dashboard-page">
+ <img src="/t/stationbuilder/dashboard-preview.jpg" alt="仪表盘示意" loading="lazy" />
+ </div>
+</template>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+// 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,
+ };
+}
--- /dev/null
+// 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 };
+}
--- /dev/null
+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]));
+}
--- /dev/null
+/* 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);
+ }
+ }
+}
--- /dev/null
+/* 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; }
+}
--- /dev/null
+@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}}
--- /dev/null
+/* 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))}}
--- /dev/null
+* {
+ 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;
+ }
+ }
+}
--- /dev/null
+/* ============================================================
+ * 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;
+}
--- /dev/null
+{
+ "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"
+ }
+ }
+}
--- /dev/null
+/* 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%;}
--- /dev/null
+/* 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}
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+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 };
--- /dev/null
+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 },
+ }),
+ ],
+});