Compare commits
47 Commits
c2ac560527
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ae484b36 | ||
|
|
0673de9ebe | ||
|
|
15d2bc51ca | ||
|
|
0665d7a008 | ||
|
|
025835c5b3 | ||
|
|
a3e7d93872 | ||
|
|
e2e7aa5f06 | ||
|
|
8fb45bd42a | ||
|
|
e215f93ef2 | ||
|
|
bbed65e41e | ||
|
|
d4e78a666e | ||
|
|
74f3e60013 | ||
|
|
91f689c3d4 | ||
|
|
c1d5240d2c | ||
|
|
33f1163aa7 | ||
|
|
7ab0c2bba4 | ||
|
|
2368d6e6c1 | ||
|
|
b1ef3deb03 | ||
|
|
5b6d29f0f6 | ||
|
|
a059805458 | ||
|
|
6206c9556b | ||
|
|
00f7dc1686 | ||
|
|
57d3137640 | ||
|
|
cc58a6e98d | ||
|
|
b6c3bd9922 | ||
|
|
c14a6aa75c | ||
|
|
692d61105e | ||
|
|
fcec4f3db4 | ||
|
|
3dfed3a2df | ||
|
|
738476c7f9 | ||
|
|
6f6be751a9 | ||
|
|
0ace6c0413 | ||
|
|
7ceed76d50 | ||
|
|
bda06279df | ||
|
|
0b99801b26 | ||
|
|
aeaeb9aec5 | ||
|
|
35934f8088 | ||
|
|
b27bf2c83b | ||
|
|
c923e9e54d | ||
|
|
5d10902e10 | ||
|
|
697802a2af | ||
|
|
6e4ce87844 | ||
|
|
71496de8e3 | ||
|
|
771e290fb9 | ||
|
|
efddf5adee | ||
|
|
a31c620420 | ||
|
|
318105443a |
@@ -3,6 +3,13 @@ PORT=3001
|
|||||||
JWT_SECRET=change-me
|
JWT_SECRET=change-me
|
||||||
DB_PATH=
|
DB_PATH=
|
||||||
|
|
||||||
|
# login: show login page
|
||||||
|
# fixed: auto login with FIXED_LOGIN_USERNAME and open model library directly
|
||||||
|
APP_AUTH_MODE=fixed
|
||||||
|
FIXED_LOGIN_USERNAME=admin
|
||||||
|
FIXED_LOGIN_ROLE=admin
|
||||||
|
ALLOW_USER_EDIT_SYSTEM_LIBRARY=false
|
||||||
|
|
||||||
# local: write to server/storage and serve via /storage
|
# local: write to server/storage and serve via /storage
|
||||||
# cos: upload models to Tencent Cloud COS and read through CDN_BASE_URL
|
# cos: upload models to Tencent Cloud COS and read through CDN_BASE_URL
|
||||||
STORAGE_DRIVER=local
|
STORAGE_DRIVER=local
|
||||||
|
|||||||
140
package-lock.json
generated
140
package-lock.json
generated
@@ -1114,20 +1114,6 @@
|
|||||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/three": {
|
|
||||||
"version": "0.184.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
|
|
||||||
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
|
||||||
"@types/stats.js": "*",
|
|
||||||
"@types/webxr": ">=0.5.17",
|
|
||||||
"fflate": "~0.8.2",
|
|
||||||
"meshoptimizer": "~1.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/webxr": {
|
"node_modules/@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
@@ -1146,6 +1132,12 @@
|
|||||||
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
|
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@webgpu/types": {
|
||||||
|
"version": "0.1.70",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz",
|
||||||
|
"integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -2320,12 +2312,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/meshoptimizer": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
@@ -3088,11 +3074,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="
|
||||||
},
|
},
|
||||||
"node_modules/three": {
|
|
||||||
"version": "0.184.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
|
||||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -3766,11 +3747,6 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/w2ui": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/w2ui/-/w2ui-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-oMvLitt2jMholqCpX0mDZ0b70ITjlLpfV56gswk7/cD2oPmlibV+DqcizESSs3+t5VJHiG0Xlcb23e4AoCN1Qw=="
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3882,17 +3858,42 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"jstree": "^3.3.17",
|
"jstree": "^3.3.17",
|
||||||
"three": "^0.184.0",
|
"three": "^0.183.2"
|
||||||
"w2ui": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jquery": "^3.5.32",
|
"@types/jquery": "^3.5.32",
|
||||||
"@types/jstree": "^3.3.46",
|
"@types/jstree": "^3.3.46",
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.183.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"web/node_modules/@types/three": {
|
||||||
|
"version": "0.183.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
|
||||||
|
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"@webgpu/types": "*",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web/node_modules/meshoptimizer": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"web/node_modules/three": {
|
||||||
|
"version": "0.183.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
|
||||||
|
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ=="
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4475,20 +4476,6 @@
|
|||||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/three": {
|
|
||||||
"version": "0.184.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
|
|
||||||
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
|
||||||
"@types/stats.js": "*",
|
|
||||||
"@types/webxr": ">=0.5.17",
|
|
||||||
"fflate": "~0.8.2",
|
|
||||||
"meshoptimizer": "~1.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@types/webxr": {
|
"@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
@@ -4502,6 +4489,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"@webgpu/types": {
|
||||||
|
"version": "0.1.70",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz",
|
||||||
|
"integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"abstract-logging": {
|
"abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -4824,7 +4817,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||||
"dotenv": "*",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
@@ -4837,14 +4830,41 @@
|
|||||||
"requires": {
|
"requires": {
|
||||||
"@types/jquery": "^3.5.32",
|
"@types/jquery": "^3.5.32",
|
||||||
"@types/jstree": "^3.3.46",
|
"@types/jstree": "^3.3.46",
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.183.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"jstree": "^3.3.17",
|
"jstree": "^3.3.17",
|
||||||
"three": "^0.184.0",
|
"three": "^0.183.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7"
|
||||||
"w2ui": "^2.0.0"
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/three": {
|
||||||
|
"version": "0.183.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
|
||||||
|
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"@webgpu/types": "*",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meshoptimizer": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"three": {
|
||||||
|
"version": "0.183.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
|
||||||
|
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ=="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dot-prop": {
|
"dot-prop": {
|
||||||
@@ -5360,12 +5380,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meshoptimizer": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"mime": {
|
"mime": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
@@ -5868,11 +5882,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"three": {
|
|
||||||
"version": "0.184.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
|
||||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
|
|
||||||
},
|
|
||||||
"tinyglobby": {
|
"tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -6205,11 +6214,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"w2ui": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/w2ui/-/w2ui-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-oMvLitt2jMholqCpX0mDZ0b70ITjlLpfV56gswk7/cD2oPmlibV+DqcizESSs3+t5VJHiG0Xlcb23e4AoCN1Qw=="
|
|
||||||
},
|
|
||||||
"which": {
|
"which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { config } from "./config.js";
|
||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
import { AuthUser, UserRow } from "./types.js";
|
import { AuthUser, UserRow } from "./types.js";
|
||||||
|
|
||||||
@@ -60,7 +61,57 @@ function normalizeExpiresAt(value: string | null | undefined) {
|
|||||||
return clean.length === 10 ? `${clean}T23:59:59` : clean;
|
return clean.length === 10 ? `${clean}T23:59:59` : clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureFixedLoginUser() {
|
||||||
|
const username = config.fixedLogin.username.trim() || "viewer";
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
`).get(username) as unknown as UserRow | undefined;
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET role = ?, enabled = 1, expires_at = NULL, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(config.fixedLogin.role, existing.id);
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(existing.id) as unknown as UserRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(`fixed-${Date.now()}-${Math.random()}`, 10);
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO users (username, password_hash, role, enabled, expires_at)
|
||||||
|
VALUES (?, ?, ?, 1, NULL)
|
||||||
|
`).run(username, passwordHash, config.fixedLogin.role);
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(Number(result.lastInsertRowid)) as unknown as UserRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authUser(row: Pick<UserRow, "id" | "username" | "role">): AuthUser {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
role: row.role
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/api/auth/bootstrap", async () => {
|
||||||
|
if (config.authMode !== "fixed") {
|
||||||
|
return { mode: "login" };
|
||||||
|
}
|
||||||
|
const user = await ensureFixedLoginUser();
|
||||||
|
const payload = authUser(user);
|
||||||
|
const token = app.jwt.sign(payload);
|
||||||
|
return { mode: "fixed", token, user: payload };
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/auth/register", async (request, reply) => {
|
app.post("/api/auth/register", async (request, reply) => {
|
||||||
const body = registerSchema.parse(request.body);
|
const body = registerSchema.parse(request.body);
|
||||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||||
@@ -98,8 +149,9 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(403).send({ message: "账号授权已过期" });
|
return reply.code(403).send({ message: "账号授权已过期" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = app.jwt.sign({ id: user.id, username: user.username, role: user.role });
|
const payload = authUser(user);
|
||||||
return { token, user: { id: user.id, username: user.username, role: user.role } };
|
const token = app.jwt.sign(payload);
|
||||||
|
return { token, user: payload };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/auth/me", { preHandler: [requireAuth] }, async (request) => {
|
app.get("/api/auth/me", { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import type { UserRole } from "./types.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const serverRoot = path.resolve(__dirname, "..");
|
const serverRoot = path.resolve(__dirname, "..");
|
||||||
@@ -8,21 +9,36 @@ const serverRoot = path.resolve(__dirname, "..");
|
|||||||
dotenv.config({ path: path.resolve(serverRoot, "..", ".env") });
|
dotenv.config({ path: path.resolve(serverRoot, "..", ".env") });
|
||||||
dotenv.config({ path: path.resolve(serverRoot, ".env") });
|
dotenv.config({ path: path.resolve(serverRoot, ".env") });
|
||||||
|
|
||||||
|
function fixedLoginRole(): UserRole {
|
||||||
|
return process.env.FIXED_LOGIN_ROLE === "admin" ? "admin" : "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
function envValue(name: string) {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
return value || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
host: process.env.HOST ?? "0.0.0.0",
|
host: envValue("HOST") ?? "0.0.0.0",
|
||||||
port: Number(process.env.PORT ?? 3001),
|
port: Number(envValue("PORT") ?? 3001),
|
||||||
jwtSecret: process.env.JWT_SECRET ?? "dev-only-change-me",
|
jwtSecret: envValue("JWT_SECRET") ?? "dev-only-change-me",
|
||||||
|
authMode: process.env.APP_AUTH_MODE === "fixed" ? "fixed" : "login",
|
||||||
|
fixedLogin: {
|
||||||
|
username: envValue("FIXED_LOGIN_USERNAME") ?? "viewer",
|
||||||
|
role: fixedLoginRole()
|
||||||
|
},
|
||||||
|
allowUserEditSystemLibrary: envValue("ALLOW_USER_EDIT_SYSTEM_LIBRARY") === "true",
|
||||||
dataDir: path.resolve(serverRoot, "data"),
|
dataDir: path.resolve(serverRoot, "data"),
|
||||||
storageDir: path.resolve(serverRoot, "storage"),
|
storageDir: path.resolve(serverRoot, "storage"),
|
||||||
databasePath: process.env.DB_PATH ?? path.resolve(serverRoot, "data", "model-library.db"),
|
databasePath: envValue("DB_PATH") ?? path.resolve(serverRoot, "data", "model-library.db"),
|
||||||
storageDriver: (process.env.STORAGE_DRIVER ?? "local").toLowerCase(),
|
storageDriver: (envValue("STORAGE_DRIVER") ?? "local").toLowerCase(),
|
||||||
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? "",
|
publicBaseUrl: envValue("PUBLIC_BASE_URL") ?? "",
|
||||||
cdnBaseUrl: process.env.CDN_BASE_URL ?? "",
|
cdnBaseUrl: envValue("CDN_BASE_URL") ?? "",
|
||||||
cos: {
|
cos: {
|
||||||
secretId: process.env.COS_SECRET_ID ?? "",
|
secretId: envValue("COS_SECRET_ID") ?? "",
|
||||||
secretKey: process.env.COS_SECRET_KEY ?? "",
|
secretKey: envValue("COS_SECRET_KEY") ?? "",
|
||||||
bucket: process.env.COS_BUCKET ?? "",
|
bucket: envValue("COS_BUCKET") ?? "",
|
||||||
region: process.env.COS_REGION ?? "",
|
region: envValue("COS_REGION") ?? "",
|
||||||
prefix: (process.env.COS_PREFIX ?? "").replace(/^\/+|\/+$/g, "")
|
prefix: (envValue("COS_PREFIX") ?? "").replace(/^\/+|\/+$/g, "")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ export const db = new DatabaseSync(config.databasePath);
|
|||||||
db.exec("PRAGMA journal_mode = WAL");
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
db.exec("PRAGMA foreign_keys = ON");
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
function hasColumn(table: string, column: string) {
|
|
||||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[];
|
|
||||||
return rows.some((row) => row.name === column);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function migrate() {
|
export function migrate() {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -33,6 +28,9 @@ export function migrate() {
|
|||||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
library_type TEXT NOT NULL DEFAULT 'system',
|
||||||
|
owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(parent_id, name)
|
UNIQUE(parent_id, name)
|
||||||
@@ -60,11 +58,13 @@ export function migrate() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
original_filename TEXT NOT NULL,
|
original_filename TEXT NOT NULL,
|
||||||
file_path TEXT NOT NULL UNIQUE,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
|
library_type TEXT NOT NULL DEFAULT 'system',
|
||||||
|
owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
storage_provider TEXT NOT NULL DEFAULT 'local',
|
storage_provider TEXT NOT NULL DEFAULT 'local',
|
||||||
file_size INTEGER NOT NULL,
|
file_size INTEGER NOT NULL,
|
||||||
thumbnail TEXT,
|
|
||||||
thumbnail_path TEXT,
|
thumbnail_path TEXT,
|
||||||
thumbnail_provider TEXT,
|
thumbnail_provider TEXT,
|
||||||
|
operation_tree TEXT NOT NULL DEFAULT '{"OperationTree":"[]"}',
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(folder_id, name)
|
UNIQUE(folder_id, name)
|
||||||
@@ -83,32 +83,13 @@ export function migrate() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_model_properties_model_id ON model_properties(model_id);
|
CREATE INDEX IF NOT EXISTS idx_model_properties_model_id ON model_properties(model_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (!hasColumn("models", "brand_id")) {
|
|
||||||
db.exec("ALTER TABLE models ADD COLUMN brand_id INTEGER REFERENCES brands(id) ON DELETE SET NULL");
|
|
||||||
}
|
|
||||||
if (!hasColumn("models", "type_id")) {
|
|
||||||
db.exec("ALTER TABLE models ADD COLUMN type_id INTEGER REFERENCES model_types(id) ON DELETE SET NULL");
|
|
||||||
}
|
|
||||||
if (!hasColumn("models", "storage_provider")) {
|
|
||||||
db.exec("ALTER TABLE models ADD COLUMN storage_provider TEXT NOT NULL DEFAULT 'local'");
|
|
||||||
}
|
|
||||||
if (!hasColumn("models", "thumbnail_path")) {
|
|
||||||
db.exec("ALTER TABLE models ADD COLUMN thumbnail_path TEXT");
|
|
||||||
}
|
|
||||||
if (!hasColumn("models", "thumbnail_provider")) {
|
|
||||||
db.exec("ALTER TABLE models ADD COLUMN thumbnail_provider TEXT");
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_models_brand_id ON models(brand_id);
|
CREATE INDEX IF NOT EXISTS idx_models_brand_id ON models(brand_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_models_type_id ON models(type_id);
|
CREATE INDEX IF NOT EXISTS idx_models_type_id ON models(type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_library_owner ON folders(library_type, owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_library_owner ON models(library_type, owner_user_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const rootFolder = db.prepare("SELECT id FROM folders WHERE parent_id IS NULL AND name = ?").get("模型库");
|
|
||||||
if (!rootFolder) {
|
|
||||||
db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (NULL, ?, ?)").run("模型库", "模型库");
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = db.prepare("SELECT id FROM users WHERE username = ?").get("admin");
|
const admin = db.prepare("SELECT id FROM users WHERE username = ?").get("admin");
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
@@ -116,4 +97,12 @@ export function migrate() {
|
|||||||
VALUES (?, ?, 'admin', 1)
|
VALUES (?, ?, 'admin', 1)
|
||||||
`).run("admin", bcrypt.hashSync("admin123", 10));
|
`).run("admin", bcrypt.hashSync("admin123", 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemRoot = db.prepare("SELECT id FROM folders WHERE path = ?").get("system") as { id: number } | undefined;
|
||||||
|
if (!systemRoot) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system)
|
||||||
|
VALUES (NULL, ?, ?, 'system', NULL, 1)
|
||||||
|
`).run("系统模型库", "system");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
import { requireAdmin, requireAuth } from "./auth.js";
|
import { requireAuth } from "./auth.js";
|
||||||
|
import { folderPermissions, getFolder, requireFolderWrite } from "./permissions.js";
|
||||||
import { storage } from "./storage.js";
|
import { storage } from "./storage.js";
|
||||||
import { FolderRow } from "./types.js";
|
import { FolderRow } from "./types.js";
|
||||||
import { assertValidFolderName, ensureDir, removeIfExists, safeStoragePath, toFolderStorageRelative } from "./utils.js";
|
import { assertValidFolderName, ensureDir, removeIfExists, safeStoragePath, toFolderStorageRelative } from "./utils.js";
|
||||||
@@ -17,65 +18,131 @@ const updateSchema = z.object({
|
|||||||
name: z.string()
|
name: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildTree(rows: FolderRow[]) {
|
function folderPayload(request: { userInfo: { id: number; username: string; role: "admin" | "user" } }, row: FolderRow) {
|
||||||
const nodes = rows.map((row) => ({
|
return {
|
||||||
|
...row,
|
||||||
|
permissions: folderPermissions(request.userInfo, row)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderNodes(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) {
|
||||||
|
return rows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
text: `${row.name}(${row.model_count ?? 0})`,
|
text: `${displayFolderName(row)}(${row.model_count ?? 0})`,
|
||||||
parentId: row.parent_id,
|
parentId: row.parent_id,
|
||||||
path: row.path,
|
path: row.path,
|
||||||
|
data: {
|
||||||
|
permissions: folderPermissions(userInfo, row),
|
||||||
|
libraryType: row.library_type,
|
||||||
|
ownerUserId: row.owner_user_id,
|
||||||
|
isSystem: Boolean(row.is_system)
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
opened: row.library_type === "system" && row.parent_id === null
|
||||||
|
},
|
||||||
children: [] as unknown[]
|
children: [] as unknown[]
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) {
|
||||||
|
const nodes = buildFolderNodes(rows, userInfo);
|
||||||
|
const userGroupNode = {
|
||||||
|
id: "virtual:user-libraries",
|
||||||
|
text: "用户模型库",
|
||||||
|
parentId: null as number | null,
|
||||||
|
path: "users",
|
||||||
|
data: {
|
||||||
|
permissions: { read: true, write: false },
|
||||||
|
virtual: true
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
opened: true
|
||||||
|
},
|
||||||
|
children: [] as unknown[]
|
||||||
|
};
|
||||||
const byId = new Map(nodes.map((node) => [Number(node.id), node]));
|
const byId = new Map(nodes.map((node) => [Number(node.id), node]));
|
||||||
const roots = [];
|
const roots = [];
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.parentId && byId.has(node.parentId)) {
|
if (node.parentId && byId.has(node.parentId)) {
|
||||||
byId.get(node.parentId)!.children.push(node);
|
byId.get(node.parentId)!.children.push(node);
|
||||||
|
} else if (node.data.libraryType === "user") {
|
||||||
|
userGroupNode.children.push(node);
|
||||||
} else {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (userGroupNode.children.length > 0) {
|
||||||
|
roots.push(userGroupNode);
|
||||||
|
}
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFolder(id: number) {
|
|
||||||
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function childPath(parent: FolderRow | undefined, name: string) {
|
function childPath(parent: FolderRow | undefined, name: string) {
|
||||||
return parent ? path.posix.join(parent.path, name) : name;
|
return parent ? path.posix.join(parent.path, name) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertCanRenameRoot(folder: FolderRow) {
|
function assertCanRenameRoot(folder: FolderRow) {
|
||||||
if (folder.parent_id === null) {
|
if (folder.is_system) {
|
||||||
throw new Error("根目录不能重命名");
|
throw new Error("系统保留目录不能重命名");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayFolderName(row: FolderRow) {
|
||||||
|
if (row.library_type === "user" && row.is_system && row.owner_username) {
|
||||||
|
return row.owner_username;
|
||||||
|
}
|
||||||
|
return row.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUserLibrary(user: { id: number; username: string }) {
|
||||||
|
const pathValue = `users/${user.id}`;
|
||||||
|
const existing = db.prepare("SELECT id FROM folders WHERE path = ?").get(pathValue) as { id: number } | undefined;
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system)
|
||||||
|
VALUES (NULL, ?, ?, 'user', ?, 1)
|
||||||
|
`).run("用户模型库", pathValue, user.id);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
export async function folderRoutes(app: FastifyInstance) {
|
export async function folderRoutes(app: FastifyInstance) {
|
||||||
app.get("/api/folders", { preHandler: [requireAuth] }, async () => {
|
app.get("/api/folders", { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
ensureUserLibrary(request.userInfo);
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
f.*,
|
f.*,
|
||||||
|
u.username AS owner_username,
|
||||||
COUNT(m.id) AS model_count
|
COUNT(m.id) AS model_count
|
||||||
FROM folders f
|
FROM folders f
|
||||||
|
LEFT JOIN users u ON u.id = f.owner_user_id
|
||||||
LEFT JOIN models m ON m.folder_id = f.id
|
LEFT JOIN models m ON m.folder_id = f.id
|
||||||
GROUP BY f.id
|
GROUP BY f.id
|
||||||
ORDER BY f.parent_id, f.name
|
ORDER BY
|
||||||
|
CASE f.library_type WHEN 'system' THEN 0 ELSE 1 END,
|
||||||
|
f.owner_user_id,
|
||||||
|
f.parent_id,
|
||||||
|
f.name
|
||||||
`).all() as unknown as FolderRow[];
|
`).all() as unknown as FolderRow[];
|
||||||
return { folders: rows, tree: buildTree(rows) };
|
return { folders: rows.map((row) => folderPayload(request, row)), tree: buildTree(rows, request.userInfo) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/folders", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.post("/api/folders", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const body = createSchema.parse(request.body);
|
const body = createSchema.parse(request.body);
|
||||||
const name = assertValidFolderName(body.name);
|
const name = assertValidFolderName(body.name);
|
||||||
const parent = body.parentId ? getFolder(body.parentId) : undefined;
|
const parent = body.parentId ? getFolder(body.parentId) : undefined;
|
||||||
if (body.parentId && !parent) {
|
if (body.parentId && !parent) {
|
||||||
return reply.code(404).send({ message: "父目录不存在" });
|
return reply.code(404).send({ message: "父目录不存在" });
|
||||||
}
|
}
|
||||||
|
if (!parent) {
|
||||||
|
return reply.code(400).send({ message: "请选择父目录" });
|
||||||
|
}
|
||||||
|
if (!requireFolderWrite(request, reply, parent)) return;
|
||||||
const folderPath = childPath(parent, name);
|
const folderPath = childPath(parent, name);
|
||||||
try {
|
try {
|
||||||
const result = db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (?, ?, ?)")
|
const result = db.prepare(`
|
||||||
.run(body.parentId ?? null, name, folderPath);
|
INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)
|
||||||
|
`).run(parent.id, name, folderPath, parent.library_type, parent.owner_user_id);
|
||||||
await ensureDir(safeStoragePath(toFolderStorageRelative(folderPath)));
|
await ensureDir(safeStoragePath(toFolderStorageRelative(folderPath)));
|
||||||
return reply.code(201).send({ id: result.lastInsertRowid, name, path: folderPath });
|
return reply.code(201).send({ id: result.lastInsertRowid, name, path: folderPath });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -83,7 +150,7 @@ export async function folderRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.put("/api/folders/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||||
const body = updateSchema.parse(request.body);
|
const body = updateSchema.parse(request.body);
|
||||||
const name = assertValidFolderName(body.name);
|
const name = assertValidFolderName(body.name);
|
||||||
@@ -91,6 +158,7 @@ export async function folderRoutes(app: FastifyInstance) {
|
|||||||
if (!folder) {
|
if (!folder) {
|
||||||
return reply.code(404).send({ message: "目录不存在" });
|
return reply.code(404).send({ message: "目录不存在" });
|
||||||
}
|
}
|
||||||
|
if (!requireFolderWrite(request, reply, folder)) return;
|
||||||
assertCanRenameRoot(folder);
|
assertCanRenameRoot(folder);
|
||||||
const parent = folder.parent_id ? getFolder(folder.parent_id) : undefined;
|
const parent = folder.parent_id ? getFolder(folder.parent_id) : undefined;
|
||||||
const oldPath = folder.path;
|
const oldPath = folder.path;
|
||||||
@@ -146,14 +214,15 @@ export async function folderRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.delete("/api/folders/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||||
const folder = getFolder(params.id);
|
const folder = getFolder(params.id);
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
return reply.code(404).send({ message: "目录不存在" });
|
return reply.code(404).send({ message: "目录不存在" });
|
||||||
}
|
}
|
||||||
if (folder.parent_id === null) {
|
if (!requireFolderWrite(request, reply, folder)) return;
|
||||||
return reply.code(400).send({ message: "根目录不能删除" });
|
if (folder.is_system) {
|
||||||
|
return reply.code(400).send({ message: "系统保留目录不能删除" });
|
||||||
}
|
}
|
||||||
db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id);
|
db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id);
|
||||||
await removeIfExists(safeStoragePath(toFolderStorageRelative(folder.path)));
|
await removeIfExists(safeStoragePath(toFolderStorageRelative(folder.path)));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
import { requireAdmin, requireAuth } from "./auth.js";
|
import { requireAdmin, requireAuth } from "./auth.js";
|
||||||
|
import { getFolder, modelPermissions, requireFolderWrite, requireModelWrite } from "./permissions.js";
|
||||||
import { deleteStoredObject, publicObjectUrl, storage } from "./storage.js";
|
import { deleteStoredObject, publicObjectUrl, storage } from "./storage.js";
|
||||||
import { DictionaryRow, FolderRow, ModelRow } from "./types.js";
|
import { DictionaryRow, FolderRow, ModelRow } from "./types.js";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,7 @@ const updateSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
brandName: z.string().optional(),
|
brandName: z.string().optional(),
|
||||||
typeName: z.string().optional(),
|
typeName: z.string().optional(),
|
||||||
thumbnail: z.string().nullable().optional(),
|
operationTree: z.string().optional(),
|
||||||
properties: z.record(z.string()).optional()
|
properties: z.record(z.string()).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,9 +33,7 @@ const dictionaryBodySchema = z.object({
|
|||||||
name: z.string().min(1).max(80)
|
name: z.string().min(1).max(80)
|
||||||
});
|
});
|
||||||
|
|
||||||
function getFolder(id: number) {
|
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||||
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProperties(modelId: number) {
|
function getProperties(modelId: number) {
|
||||||
const rows = db.prepare("SELECT property_key, property_value FROM model_properties WHERE model_id = ?")
|
const rows = db.prepare("SELECT property_key, property_value FROM model_properties WHERE model_id = ?")
|
||||||
@@ -42,6 +41,24 @@ function getProperties(modelId: number) {
|
|||||||
return Object.fromEntries(rows.map((row) => [row.property_key, row.property_value]));
|
return Object.fromEntries(rows.map((row) => [row.property_key, row.property_value]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPropertiesMap(modelIds: number[]) {
|
||||||
|
const properties = new Map<number, Record<string, string>>();
|
||||||
|
if (modelIds.length === 0) return properties;
|
||||||
|
const placeholders = modelIds.map(() => "?").join(",");
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT model_id, property_key, property_value
|
||||||
|
FROM model_properties
|
||||||
|
WHERE model_id IN (${placeholders})
|
||||||
|
`).all(...modelIds) as { model_id: number; property_key: string; property_value: string }[];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const modelProperties = properties.get(row.model_id) ?? {};
|
||||||
|
modelProperties[row.property_key] = row.property_value;
|
||||||
|
properties.set(row.model_id, modelProperties);
|
||||||
|
}
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
function saveProperties(modelId: number, properties: Record<string, string>) {
|
function saveProperties(modelId: number, properties: Record<string, string>) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO model_properties (model_id, property_key, property_value)
|
INSERT INTO model_properties (model_id, property_key, property_value)
|
||||||
@@ -56,17 +73,34 @@ function saveProperties(modelId: number, properties: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelPayload(row: ModelRow) {
|
function modelPayload(row: ModelRow, userInfo?: { id: number; username: string; role: "admin" | "user" }) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
file_url: publicObjectUrl(row.file_path, row.storage_provider),
|
file_url: publicObjectUrl(row.file_path, row.storage_provider),
|
||||||
thumbnail_url: row.thumbnail_path && row.thumbnail_provider
|
thumbnail_url: row.thumbnail_path && row.thumbnail_provider
|
||||||
? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider)
|
? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider)
|
||||||
: row.thumbnail,
|
: null,
|
||||||
|
permissions: userInfo ? modelPermissions(userInfo, row) : { read: true, write: false },
|
||||||
properties: getProperties(row.id)
|
properties: getProperties(row.id)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelListPayload(
|
||||||
|
rows: ModelRow[],
|
||||||
|
userInfo?: { id: number; username: string; role: "admin" | "user" }
|
||||||
|
) {
|
||||||
|
const propertiesMap = getPropertiesMap(rows.map((row) => row.id));
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
file_url: publicObjectUrl(row.file_path, row.storage_provider),
|
||||||
|
thumbnail_url: row.thumbnail_path && row.thumbnail_provider
|
||||||
|
? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider)
|
||||||
|
: null,
|
||||||
|
permissions: userInfo ? modelPermissions(userInfo, row) : { read: true, write: false },
|
||||||
|
properties: propertiesMap.get(row.id) ?? {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function dataImageToBuffer(dataUrl: string) {
|
function dataImageToBuffer(dataUrl: string) {
|
||||||
const match = dataUrl.match(/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/);
|
const match = dataUrl.match(/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -78,6 +112,26 @@ function dataImageToBuffer(dataUrl: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOperationTree(value: string | undefined) {
|
||||||
|
const clean = value?.trim();
|
||||||
|
if (!clean) return defaultOperationTree;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(clean);
|
||||||
|
} catch {
|
||||||
|
throw new Error("工艺数据必须是合法 JSON 字符串");
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || typeof (parsed as { OperationTree?: unknown }).OperationTree !== "string") {
|
||||||
|
throw new Error("工艺数据格式必须符合 OperationTreeDB");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse((parsed as { OperationTree: string }).OperationTree);
|
||||||
|
} catch {
|
||||||
|
throw new Error("OperationTree 字段必须是合法 JSON 字符串");
|
||||||
|
}
|
||||||
|
return JSON.stringify(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDictionaryName(name: string | undefined) {
|
function normalizeDictionaryName(name: string | undefined) {
|
||||||
const clean = name?.trim();
|
const clean = name?.trim();
|
||||||
return clean || null;
|
return clean || null;
|
||||||
@@ -173,28 +227,32 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
whereParts.push("m.type_id = ?");
|
whereParts.push("m.type_id = ?");
|
||||||
params.push(query.typeId);
|
params.push(query.typeId);
|
||||||
}
|
}
|
||||||
if (query.keyword?.trim()) {
|
const keyword = query.keyword?.trim() ?? "";
|
||||||
|
const hasKeyword = Boolean(keyword);
|
||||||
|
if (hasKeyword) {
|
||||||
whereParts.push("(m.name LIKE ? OR m.original_filename LIKE ? OR mp.property_value LIKE ?)");
|
whereParts.push("(m.name LIKE ? OR m.original_filename LIKE ? OR mp.property_value LIKE ?)");
|
||||||
const like = `%${query.keyword.trim()}%`;
|
const like = `%${keyword}%`;
|
||||||
params.push(like, like, like);
|
params.push(like, like, like);
|
||||||
}
|
}
|
||||||
const where = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : "";
|
const where = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : "";
|
||||||
|
const propertyJoin = hasKeyword ? "LEFT JOIN model_properties mp ON mp.model_id = m.id" : "";
|
||||||
|
const distinct = hasKeyword ? "DISTINCT" : "";
|
||||||
const totalStmt = db.prepare(`
|
const totalStmt = db.prepare(`
|
||||||
SELECT COUNT(DISTINCT m.id) as count
|
SELECT COUNT(${distinct} m.id) as count
|
||||||
FROM models m
|
FROM models m
|
||||||
LEFT JOIN model_properties mp ON mp.model_id = m.id
|
${propertyJoin}
|
||||||
${where}
|
${where}
|
||||||
`);
|
`);
|
||||||
const total = totalStmt.get(...params) as { count: number };
|
const total = totalStmt.get(...params) as { count: number };
|
||||||
const listStmt = db.prepare(`
|
const listStmt = db.prepare(`
|
||||||
SELECT DISTINCT
|
SELECT ${distinct}
|
||||||
m.*,
|
m.*,
|
||||||
b.name AS brand_name,
|
b.name AS brand_name,
|
||||||
t.name AS type_name
|
t.name AS type_name
|
||||||
FROM models m
|
FROM models m
|
||||||
LEFT JOIN brands b ON b.id = m.brand_id
|
LEFT JOIN brands b ON b.id = m.brand_id
|
||||||
LEFT JOIN model_types t ON t.id = m.type_id
|
LEFT JOIN model_types t ON t.id = m.type_id
|
||||||
LEFT JOIN model_properties mp ON mp.model_id = m.id
|
${propertyJoin}
|
||||||
${where}
|
${where}
|
||||||
ORDER BY m.updated_at DESC, m.id DESC
|
ORDER BY m.updated_at DESC, m.id DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
@@ -205,19 +263,19 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
total: total.count,
|
total: total.count,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
items: rows.map(modelPayload)
|
items: modelListPayload(rows, request.userInfo)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/models/upload", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.post("/api/models/upload", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
let folderId: number | undefined;
|
let folderId: number | undefined;
|
||||||
let thumbnail: string | null = null;
|
|
||||||
const properties: Record<string, string> = {};
|
const properties: Record<string, string> = {};
|
||||||
let uploaded: { filename: string; buffer: Buffer } | undefined;
|
let uploaded: { filename: string; buffer: Buffer } | undefined;
|
||||||
let modelName: string | undefined;
|
let modelName: string | undefined;
|
||||||
let brandName: string | undefined;
|
let brandName: string | undefined;
|
||||||
let typeName: string | undefined;
|
let typeName: string | undefined;
|
||||||
|
let operationTree: string | undefined;
|
||||||
|
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
@@ -234,7 +292,7 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
if (part.fieldname === "name") modelName = String(part.value ?? "");
|
if (part.fieldname === "name") modelName = String(part.value ?? "");
|
||||||
if (part.fieldname === "brandName") brandName = String(part.value ?? "");
|
if (part.fieldname === "brandName") brandName = String(part.value ?? "");
|
||||||
if (part.fieldname === "typeName") typeName = String(part.value ?? "");
|
if (part.fieldname === "typeName") typeName = String(part.value ?? "");
|
||||||
if (part.fieldname === "thumbnail") thumbnail = String(part.value || "") || null;
|
if (part.fieldname === "operationTree") operationTree = String(part.value ?? "");
|
||||||
if (part.fieldname.startsWith("prop.")) {
|
if (part.fieldname.startsWith("prop.")) {
|
||||||
properties[part.fieldname.slice(5)] = String(part.value ?? "");
|
properties[part.fieldname.slice(5)] = String(part.value ?? "");
|
||||||
}
|
}
|
||||||
@@ -248,6 +306,7 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
if (!folder) {
|
if (!folder) {
|
||||||
return reply.code(404).send({ message: "目录不存在" });
|
return reply.code(404).send({ message: "目录不存在" });
|
||||||
}
|
}
|
||||||
|
if (!requireFolderWrite(request, reply, folder)) return;
|
||||||
|
|
||||||
const originalModelName = assertValidFolderName(modelName || normalizeModelName(uploaded.filename));
|
const originalModelName = assertValidFolderName(modelName || normalizeModelName(uploaded.filename));
|
||||||
const storageFilename = `${normalizeModelName(uploaded.filename)}-${nanoid(8)}.glb`;
|
const storageFilename = `${normalizeModelName(uploaded.filename)}-${nanoid(8)}.glb`;
|
||||||
@@ -262,10 +321,11 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
const brandId = findOrCreateDictionary("brands", brandName);
|
const brandId = findOrCreateDictionary("brands", brandName);
|
||||||
const typeId = findOrCreateDictionary("model_types", typeName);
|
const typeId = findOrCreateDictionary("model_types", typeName);
|
||||||
|
const normalizedOperationTree = normalizeOperationTree(operationTree);
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, storage_provider, file_size, thumbnail)
|
INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, library_type, owner_user_id, storage_provider, file_size, operation_tree)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, stored.provider, uploaded.buffer.length, thumbnail);
|
`).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, folder.library_type, folder.owner_user_id, stored.provider, uploaded.buffer.length, normalizedOperationTree);
|
||||||
saveProperties(Number(result.lastInsertRowid), properties);
|
saveProperties(Number(result.lastInsertRowid), properties);
|
||||||
return reply.code(201).send({ id: result.lastInsertRowid });
|
return reply.code(201).send({ id: result.lastInsertRowid });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -274,22 +334,24 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.put("/api/models/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||||
const body = updateSchema.parse(request.body);
|
const body = updateSchema.parse(request.body);
|
||||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return reply.code(404).send({ message: "模型不存在" });
|
return reply.code(404).send({ message: "模型不存在" });
|
||||||
}
|
}
|
||||||
|
if (!requireModelWrite(request, reply, model)) return;
|
||||||
const name = assertValidFolderName(body.name);
|
const name = assertValidFolderName(body.name);
|
||||||
try {
|
try {
|
||||||
const brandId = findOrCreateDictionary("brands", body.brandName);
|
const brandId = findOrCreateDictionary("brands", body.brandName);
|
||||||
const typeId = findOrCreateDictionary("model_types", body.typeName);
|
const typeId = findOrCreateDictionary("model_types", body.typeName);
|
||||||
|
const operationTree = body.operationTree === undefined ? model.operation_tree : normalizeOperationTree(body.operationTree);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET name = ?, brand_id = ?, type_id = ?, thumbnail = ?, updated_at = CURRENT_TIMESTAMP
|
SET name = ?, brand_id = ?, type_id = ?, operation_tree = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(name, brandId, typeId, body.thumbnail ?? model.thumbnail, model.id);
|
`).run(name, brandId, typeId, operationTree, model.id);
|
||||||
if (body.properties) {
|
if (body.properties) {
|
||||||
saveProperties(model.id, body.properties);
|
saveProperties(model.id, body.properties);
|
||||||
}
|
}
|
||||||
@@ -299,19 +361,20 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/models/:id/thumbnail", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.put("/api/models/:id/thumbnail", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||||
const body = thumbnailSchema.parse(request.body);
|
const body = thumbnailSchema.parse(request.body);
|
||||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return reply.code(404).send({ message: "模型不存在" });
|
return reply.code(404).send({ message: "模型不存在" });
|
||||||
}
|
}
|
||||||
|
if (!requireModelWrite(request, reply, model)) return;
|
||||||
const image = dataImageToBuffer(body.thumbnail);
|
const image = dataImageToBuffer(body.thumbnail);
|
||||||
const stored = await storage.putObject(`thumbnails/model-${model.id}-${nanoid(8)}.${image.ext}`, image.buffer);
|
const stored = await storage.putObject(`thumbnails/model-${model.id}-${nanoid(8)}.${image.ext}`, image.buffer);
|
||||||
try {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET thumbnail = NULL, thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP
|
SET thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(stored.key, stored.provider, model.id);
|
`).run(stored.key, stored.provider, model.id);
|
||||||
if (model.thumbnail_path && model.thumbnail_provider) {
|
if (model.thumbnail_path && model.thumbnail_provider) {
|
||||||
@@ -324,12 +387,13 @@ export async function modelRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
app.delete("/api/models/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return reply.code(404).send({ message: "模型不存在" });
|
return reply.code(404).send({ message: "模型不存在" });
|
||||||
}
|
}
|
||||||
|
if (!requireModelWrite(request, reply, model)) return;
|
||||||
db.prepare("DELETE FROM models WHERE id = ?").run(model.id);
|
db.prepare("DELETE FROM models WHERE id = ?").run(model.id);
|
||||||
await deleteStoredObject(model.file_path, model.storage_provider);
|
await deleteStoredObject(model.file_path, model.storage_provider);
|
||||||
if (model.thumbnail_path && model.thumbnail_provider) {
|
if (model.thumbnail_path && model.thumbnail_provider) {
|
||||||
|
|||||||
55
server/src/permissions.ts
Normal file
55
server/src/permissions.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { db } from "./db.js";
|
||||||
|
import { FolderRow, ModelRow } from "./types.js";
|
||||||
|
|
||||||
|
export type PermissionPayload = {
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canWriteLibrary(user: FastifyRequest["userInfo"], libraryType: "system" | "user", ownerUserId: number | null) {
|
||||||
|
if (user.role === "admin") return true;
|
||||||
|
if (libraryType === "user" && ownerUserId === user.id) return true;
|
||||||
|
if (libraryType === "system" && config.allowUserEditSystemLibrary) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderPermissions(user: FastifyRequest["userInfo"], folder: Pick<FolderRow, "library_type" | "owner_user_id">): PermissionPayload {
|
||||||
|
return {
|
||||||
|
read: true,
|
||||||
|
write: canWriteLibrary(user, folder.library_type, folder.owner_user_id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelPermissions(user: FastifyRequest["userInfo"], model: Pick<ModelRow, "library_type" | "owner_user_id">): PermissionPayload {
|
||||||
|
return {
|
||||||
|
read: true,
|
||||||
|
write: canWriteLibrary(user, model.library_type, model.owner_user_id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolder(id: number) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT f.*, u.username AS owner_username
|
||||||
|
FROM folders f
|
||||||
|
LEFT JOIN users u ON u.id = f.owner_user_id
|
||||||
|
WHERE f.id = ?
|
||||||
|
`).get(id) as FolderRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireFolderWrite(request: FastifyRequest, reply: FastifyReply, folder: FolderRow) {
|
||||||
|
if (!folderPermissions(request.userInfo, folder).write) {
|
||||||
|
reply.code(403).send({ message: "没有该模型库的操作权限" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireModelWrite(request: FastifyRequest, reply: FastifyReply, model: ModelRow) {
|
||||||
|
if (!modelPermissions(request.userInfo, model).write) {
|
||||||
|
reply.code(403).send({ message: "没有该模型的操作权限" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ export interface FolderRow {
|
|||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
library_type: "system" | "user";
|
||||||
|
owner_user_id: number | null;
|
||||||
|
is_system: number;
|
||||||
|
owner_username?: string | null;
|
||||||
model_count?: number;
|
model_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -35,11 +39,13 @@ export interface ModelRow {
|
|||||||
name: string;
|
name: string;
|
||||||
original_filename: string;
|
original_filename: string;
|
||||||
file_path: string;
|
file_path: string;
|
||||||
|
library_type: "system" | "user";
|
||||||
|
owner_user_id: number | null;
|
||||||
storage_provider: "local" | "cos";
|
storage_provider: "local" | "cos";
|
||||||
file_size: number;
|
file_size: number;
|
||||||
thumbnail: string | null;
|
|
||||||
thumbnail_path: string | null;
|
thumbnail_path: string | null;
|
||||||
thumbnail_provider: "local" | "cos" | null;
|
thumbnail_provider: "local" | "cos" | null;
|
||||||
|
operation_tree: string;
|
||||||
brand_name?: string | null;
|
brand_name?: string | null;
|
||||||
type_name?: string | null;
|
type_name?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script src="%BASE_URL%model-library-config.js"></script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,13 +11,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"jstree": "^3.3.17",
|
"jstree": "^3.3.17",
|
||||||
"three": "^0.184.0",
|
"three": "^0.183.2"
|
||||||
"w2ui": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jquery": "^3.5.32",
|
"@types/jquery": "^3.5.32",
|
||||||
"@types/jstree": "^3.3.46",
|
"@types/jstree": "^3.3.46",
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.183.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
|
|||||||
BIN
web/public/DayCityOutdoor.exr
Normal file
BIN
web/public/DayCityOutdoor.exr
Normal file
Binary file not shown.
9
web/public/model-library-config.js
Normal file
9
web/public/model-library-config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
window.__DMT_MODEL_LIBRARY_CONFIG__ = {
|
||||||
|
// 后端服务根地址。为空时使用当前页面域名,例如 /api/models。
|
||||||
|
// 示例:"https://model-api.example.com"
|
||||||
|
API_BASE_URL: "https://dmt.meswork.com/modelLibrary",
|
||||||
|
|
||||||
|
// 模型文件和缩略图服务根地址。为空时优先复用 API_BASE_URL。
|
||||||
|
// 示例:"https://model-api.example.com"
|
||||||
|
STORAGE_BASE_URL: "https://dmt.meswork.com"
|
||||||
|
};
|
||||||
52
web/src/config/runtime.ts
Normal file
52
web/src/config/runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
type RuntimeConfig = {
|
||||||
|
API_BASE_URL?: string;
|
||||||
|
STORAGE_BASE_URL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__DMT_MODEL_LIBRARY_CONFIG__?: RuntimeConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeConfig() {
|
||||||
|
return window.__DMT_MODEL_LIBRARY_CONFIG__ ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanBaseUrl(value?: string) {
|
||||||
|
return value?.trim().replace(/\/+$/g, "") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteUrl(url: string) {
|
||||||
|
return /^https?:\/\//i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(baseUrl: string, path: string) {
|
||||||
|
if (!baseUrl || isAbsoluteUrl(path)) return path;
|
||||||
|
return `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinApiUrl(baseUrl: string, path: string) {
|
||||||
|
if (!baseUrl || isAbsoluteUrl(path)) return path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
if (base.pathname.replace(/\/+$/g, "") === "/api" && path.startsWith("/api/")) {
|
||||||
|
return joinUrl(baseUrl, path.slice(4));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非完整 URL 时按普通路径拼接。
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinUrl(baseUrl, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApiUrl(path: string) {
|
||||||
|
return joinApiUrl(cleanBaseUrl(runtimeConfig().API_BASE_URL), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStorageUrl(path: string) {
|
||||||
|
const storageBaseUrl = cleanBaseUrl(runtimeConfig().STORAGE_BASE_URL);
|
||||||
|
const apiBaseUrl = cleanBaseUrl(runtimeConfig().API_BASE_URL);
|
||||||
|
return joinUrl(storageBaseUrl || apiBaseUrl, path);
|
||||||
|
}
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
import "jstree/dist/themes/default/style.min.css";
|
import "jstree/dist/themes/default/style.min.css";
|
||||||
import "w2ui/w2ui-2.0.min.css";
|
import "./vendor/w2ui/w2ui.css";
|
||||||
import "./styles/base.css";
|
import "./styles/base.css";
|
||||||
import { api } from "./services/api";
|
import { api } from "./services/api";
|
||||||
import { getToken, setCurrentUser, clearSession } from "./services/authState";
|
import { getToken, setCurrentUser, setSession, clearSession } from "./services/authState";
|
||||||
import type { AuthUser } from "./types";
|
import type { AuthBootstrapResponse, AuthUser } from "./types";
|
||||||
import { renderLogin } from "./pages/login/login";
|
import { renderLogin } from "./pages/login/login";
|
||||||
import { renderApp } from "./pages/app/layout";
|
import { renderApp } from "./pages/app/layout";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const boot = await api<AuthBootstrapResponse>("/api/auth/bootstrap");
|
||||||
|
if (boot.mode === "fixed") {
|
||||||
|
document.body.dataset.authMode = "fixed";
|
||||||
|
setSession(boot.token, boot.user);
|
||||||
|
await renderApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete document.body.dataset.authMode;
|
||||||
|
} catch {
|
||||||
|
delete document.body.dataset.authMode;
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
renderLogin(renderApp);
|
renderLogin(renderApp);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 42px 1fr;
|
/* grid-template-rows: 42px 1fr; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
/* display: flex; */
|
||||||
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto 1fr auto;
|
grid-template-rows: auto auto 1fr auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-toolbar {
|
.content-toolbar {
|
||||||
@@ -80,17 +82,17 @@
|
|||||||
|
|
||||||
.filter-toolbar {
|
.filter-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(120px, 160px) minmax(120px, 160px) minmax(200px, 1fr) auto;
|
grid-template-columns: minmax(120px, 200px) minmax(120px, 200px) minmax(200px, 200px) minmax(50px, 80px) minmax(50px, 80px);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: #f8fafb;
|
/* background: #f8fafb; */
|
||||||
border-bottom: 1px solid #d8e0e8;
|
border-bottom: 1px solid #d8e0e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toolbar label {
|
.filter-toolbar label {
|
||||||
display: grid;
|
/* display: grid;
|
||||||
gap: 3px;
|
gap: 3px; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toolbar span {
|
.filter-toolbar span {
|
||||||
@@ -113,44 +115,46 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card {
|
.model-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d7e0e8;
|
border: 1px solid #d7e0e8;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 150px;
|
aspect-ratio: 4 / 3;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background:
|
/* background:
|
||||||
linear-gradient(45deg, #edf2f5 25%, transparent 25%),
|
linear-gradient(45deg, #edf2f5 25%, transparent 25%),
|
||||||
linear-gradient(-45deg, #edf2f5 25%, transparent 25%),
|
linear-gradient(-45deg, #edf2f5 25%, transparent 25%),
|
||||||
linear-gradient(45deg, transparent 75%, #edf2f5 75%),
|
linear-gradient(45deg, transparent 75%, #edf2f5 75%),
|
||||||
linear-gradient(-45deg, transparent 75%, #edf2f5 75%),
|
linear-gradient(-45deg, transparent 75%, #edf2f5 75%),
|
||||||
#f8fafb;
|
#f8fafb; */
|
||||||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||||||
background-size: 12px 12px;
|
background-size: 12px 12px;
|
||||||
border: 1px solid #d3dde5;
|
border: 1px solid #d3dde5;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 4px;
|
/* padding: 4px; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-actions {
|
.thumb-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, minmax(0, 86px));
|
||||||
|
align-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -169,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb-actions button {
|
.thumb-actions button {
|
||||||
width: min(86px, 80%);
|
width: 100%;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
@@ -177,6 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb img {
|
.thumb img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -253,6 +258,7 @@ dd {
|
|||||||
|
|
||||||
.pagination-total,
|
.pagination-total,
|
||||||
.pagination-range {
|
.pagination-range {
|
||||||
|
display: none;
|
||||||
color: #647586;
|
color: #647586;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -331,7 +337,7 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.keyword-filter {
|
.keyword-filter {
|
||||||
grid-column: 1 / -1;
|
/* grid-column: 1 / -1; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
|
|||||||
@@ -64,27 +64,168 @@
|
|||||||
color: #405469;
|
color: #405469;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-file-summary {
|
||||||
|
display: block;
|
||||||
|
color: #1f6f8b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-list {
|
||||||
|
display: block;
|
||||||
|
max-height: 58px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #405469;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #b9d7c1;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef8f1;
|
||||||
|
color: #27613a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-popup-form.is-uploading .drop-zone {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-panel {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
border: 1px solid #dbe5ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-title {
|
||||||
|
color: #1c2733;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-message {
|
||||||
|
color: #405469;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-track {
|
||||||
|
height: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e8eef3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-bar {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #1f6f8b;
|
||||||
|
transition: width 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-meta {
|
||||||
|
color: #27613a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-file {
|
||||||
|
max-height: 48px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #647586;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-target-path {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-target-path span {
|
||||||
|
color: #647586;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-target-path strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #243447;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-popup-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #c9d3dd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1c2733;
|
||||||
|
font: 12px/1.5 Consolas, "Microsoft YaHei", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-shell {
|
.preview-shell {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #f4f7f9;
|
background: #f4f7f9;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1fr;
|
grid-template-columns: 250px 1fr;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-process-panel {
|
.preview-process-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
border-right: 1px solid #d8e0e8;
|
border-right: 1px solid #d8e0e8;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-process-section {
|
.preview-process-section {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-process-header {
|
.preview-process-header {
|
||||||
@@ -92,7 +233,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 0 10px;
|
||||||
border-bottom: 1px solid #d8e0e8;
|
border-bottom: 1px solid #d8e0e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +244,12 @@
|
|||||||
|
|
||||||
.preview-process-tree {
|
.preview-process-tree {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-model-meta {
|
.preview-model-meta {
|
||||||
@@ -134,11 +277,22 @@
|
|||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-process-empty {
|
||||||
|
padding: 28px 8px;
|
||||||
|
color: #7a8997;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-process-tree button {
|
.preview-process-tree button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
display: block;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-process-tree li.is-active button {
|
.preview-process-tree li.is-active button {
|
||||||
@@ -155,7 +309,23 @@
|
|||||||
border-top: 1px solid #d8e0e8;
|
border-top: 1px solid #d8e0e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-process-status {
|
||||||
|
display: none;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 78px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
border-top: 1px solid #eef2f5;
|
||||||
|
color: #647586;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-scene-section {
|
.preview-scene-section {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 236px;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
border-top: 1px solid #d8e0e8;
|
border-top: 1px solid #d8e0e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +335,16 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-scene-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scene-actions > button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-scene-body > button {
|
.preview-scene-body > button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -205,13 +385,14 @@
|
|||||||
.preview-canvas-panel {
|
.preview-canvas-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-viewport {
|
.preview-viewport {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 420px;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +407,54 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
z-index: 2;
|
||||||
color: #647586;
|
color: #647586;
|
||||||
|
background: rgba(244, 247, 249, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-box {
|
||||||
|
width: min(280px, calc(100% - 40px));
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8px 24px rgba(28, 39, 51, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-box strong {
|
||||||
|
color: #243447;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-box span {
|
||||||
|
min-height: 18px;
|
||||||
|
color: #647586;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-track {
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e6edf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-track i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 10px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #1f6f8b;
|
||||||
|
transition: width 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-box.is-error {
|
||||||
|
border-color: #f0c0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-box.is-error strong {
|
||||||
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionary-manager {
|
.dictionary-manager {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { w2layout, w2ui } from "w2ui";
|
import { w2layout, w2ui } from "../../vendor/w2ui";
|
||||||
import { getCurrentUser, clearSession } from "../../services/authState";
|
import { getCurrentUser, clearSession } from "../../services/authState";
|
||||||
import { loadFolders } from "./modules/folders";
|
import { loadFolders } from "./modules/folders";
|
||||||
import { bindModelActions } from "./modules/models";
|
import { bindModelActions } from "./modules/models";
|
||||||
@@ -9,6 +9,8 @@ import "./dialogs.css";
|
|||||||
export async function renderApp() {
|
export async function renderApp() {
|
||||||
const currentUser = getCurrentUser();
|
const currentUser = getCurrentUser();
|
||||||
const isAdmin = currentUser?.role === "admin";
|
const isAdmin = currentUser?.role === "admin";
|
||||||
|
const isFixedLogin = document.body.dataset.authMode === "fixed";
|
||||||
|
const canManageUsers = isAdmin && !isFixedLogin;
|
||||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -18,7 +20,7 @@ export async function renderApp() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<span>${currentUser?.username ?? ""}</span>
|
<span>${currentUser?.username ?? ""}</span>
|
||||||
<button id="logoutBtn" class="icon-text-btn">退出</button>
|
${isFixedLogin ? "" : `<button id="logoutBtn" class="icon-text-btn">退出</button>`}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section id="workspaceLayout" class="workspace"></section>
|
<section id="workspaceLayout" class="workspace"></section>
|
||||||
@@ -30,13 +32,11 @@ export async function renderApp() {
|
|||||||
<template id="modelPanelTemplate">
|
<template id="modelPanelTemplate">
|
||||||
<main class="content-pane">
|
<main class="content-pane">
|
||||||
<div class="content-toolbar">
|
<div class="content-toolbar">
|
||||||
<div>
|
|
||||||
<h2>模型列表</h2>
|
|
||||||
</div>
|
|
||||||
<div class="content-actions">
|
<div class="content-actions">
|
||||||
${isAdmin ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
|
${canManageUsers ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
|
||||||
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
|
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
|
||||||
${isAdmin ? `<button id="addModelBtn" class="primary-btn" type="button">增加模型</button>` : ""}
|
${canManageUsers ? `<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>` : ""}
|
||||||
|
${canManageUsers ? `<button id="addModelBtn1" class="primary-btn" type="button" hidden>增加模型</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-toolbar">
|
<div class="filter-toolbar">
|
||||||
@@ -56,7 +56,8 @@ export async function renderApp() {
|
|||||||
<span>关键字</span>
|
<span>关键字</span>
|
||||||
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
||||||
</label>
|
</label>
|
||||||
<button id="resetFilterBtn" type="button">重置</button>
|
<button id="resetFilterBtn" type="button">筛选模型</button>
|
||||||
|
<button id="addModelBtn" class="primary-btn" type="button">导入模型</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="modelGrid" class="model-grid"></div>
|
<div id="modelGrid" class="model-grid"></div>
|
||||||
<footer id="modelPagination" class="pagination"></footer>
|
<footer id="modelPagination" class="pagination"></footer>
|
||||||
@@ -67,7 +68,7 @@ export async function renderApp() {
|
|||||||
|
|
||||||
renderWorkspaceLayout();
|
renderWorkspaceLayout();
|
||||||
|
|
||||||
document.querySelector("#logoutBtn")!.addEventListener("click", () => {
|
document.querySelector("#logoutBtn")?.addEventListener("click", () => {
|
||||||
clearSession();
|
clearSession();
|
||||||
renderLogin(renderApp);
|
renderLogin(renderApp);
|
||||||
});
|
});
|
||||||
@@ -90,8 +91,8 @@ function renderWorkspaceLayout() {
|
|||||||
panels: [
|
panels: [
|
||||||
{
|
{
|
||||||
type: "left",
|
type: "left",
|
||||||
size: 300,
|
size: 180,
|
||||||
minSize: 220,
|
minSize: 150,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
html: treeTemplate.innerHTML
|
html: treeTemplate.innerHTML
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export async function openDictionaryManager(onChanged?: () => Promise<void> | vo
|
|||||||
title: "品牌 / 类型维护",
|
title: "品牌 / 类型维护",
|
||||||
width: 680,
|
width: 680,
|
||||||
height: 520,
|
height: 520,
|
||||||
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<div class="dictionary-manager">
|
<div class="dictionary-manager">
|
||||||
${dictionaryPanelHtml("brands", "品牌")}
|
${dictionaryPanelHtml("brands", "品牌")}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ type JsTreeNode = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isRealFolderId(id: string) {
|
||||||
|
return /^\d+$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
function selectFolder(node: JsTreeNode) {
|
function selectFolder(node: JsTreeNode) {
|
||||||
appState.selectedFolderId = Number(node.id);
|
appState.selectedFolderId = Number(node.id);
|
||||||
appState.selectedFolderName = appState.folders.find((folder) => folder.id === Number(node.id))?.name ?? node.text;
|
appState.selectedFolderName = appState.folders.find((folder) => folder.id === Number(node.id))?.name ?? node.text;
|
||||||
@@ -59,8 +63,8 @@ async function runFolderAction(action: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFolders() {
|
export async function loadFolders(options: { reloadModels?: boolean } = {}) {
|
||||||
const isAdmin = getCurrentUser()?.role === "admin";
|
const reloadModels = options.reloadModels ?? true;
|
||||||
const result = await api<FolderTreeResponse>("/api/folders");
|
const result = await api<FolderTreeResponse>("/api/folders");
|
||||||
appState.folders = result.folders;
|
appState.folders = result.folders;
|
||||||
const root = appState.folders.find((folder) => folder.parent_id === null);
|
const root = appState.folders.find((folder) => folder.parent_id === null);
|
||||||
@@ -73,16 +77,19 @@ export async function loadFolders() {
|
|||||||
data: result.tree,
|
data: result.tree,
|
||||||
multiple: false
|
multiple: false
|
||||||
},
|
},
|
||||||
plugins: isAdmin ? ["contextmenu"] : [],
|
plugins: ["contextmenu"],
|
||||||
contextmenu: {
|
contextmenu: {
|
||||||
items(node: JsTreeNode) {
|
items(node: JsTreeNode) {
|
||||||
|
if (!isRealFolderId(node.id)) return {};
|
||||||
const folderId = Number(node.id);
|
const folderId = Number(node.id);
|
||||||
const folder = appState.folders.find((item) => item.id === folderId);
|
const folder = appState.folders.find((item) => item.id === folderId);
|
||||||
const isRoot = folder?.parent_id === null;
|
const canWrite = Boolean(folder?.permissions.write);
|
||||||
|
const isSystem = Boolean(folder?.is_system);
|
||||||
return {
|
return {
|
||||||
create: {
|
create: {
|
||||||
label: "新建目录",
|
label: "新建目录",
|
||||||
icon: "tree-menu-icon tree-menu-icon-add",
|
icon: "tree-menu-icon tree-menu-icon-add",
|
||||||
|
_disabled: !canWrite,
|
||||||
action: () => runFolderAction(async () => {
|
action: () => runFolderAction(async () => {
|
||||||
selectFolder(node);
|
selectFolder(node);
|
||||||
await createFolder(folderId);
|
await createFolder(folderId);
|
||||||
@@ -91,7 +98,7 @@ export async function loadFolders() {
|
|||||||
rename: {
|
rename: {
|
||||||
label: "重命名",
|
label: "重命名",
|
||||||
icon: "tree-menu-icon tree-menu-icon-edit",
|
icon: "tree-menu-icon tree-menu-icon-edit",
|
||||||
_disabled: isRoot,
|
_disabled: !canWrite || isSystem,
|
||||||
action: () => runFolderAction(async () => {
|
action: () => runFolderAction(async () => {
|
||||||
selectFolder(node);
|
selectFolder(node);
|
||||||
await renameFolder(folderId);
|
await renameFolder(folderId);
|
||||||
@@ -100,7 +107,7 @@ export async function loadFolders() {
|
|||||||
remove: {
|
remove: {
|
||||||
label: "删除",
|
label: "删除",
|
||||||
icon: "tree-menu-icon tree-menu-icon-delete",
|
icon: "tree-menu-icon tree-menu-icon-delete",
|
||||||
_disabled: isRoot,
|
_disabled: !canWrite || isSystem,
|
||||||
action: () => runFolderAction(async () => {
|
action: () => runFolderAction(async () => {
|
||||||
selectFolder(node);
|
selectFolder(node);
|
||||||
await deleteFolder(folderId);
|
await deleteFolder(folderId);
|
||||||
@@ -110,6 +117,7 @@ export async function loadFolders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).on("select_node.jstree", async (_event: JQuery.Event, data: { node: { id: string; text: string } }) => {
|
}).on("select_node.jstree", async (_event: JQuery.Event, data: { node: { id: string; text: string } }) => {
|
||||||
|
if (!isRealFolderId(data.node.id)) return;
|
||||||
appState.selectedFolderId = Number(data.node.id);
|
appState.selectedFolderId = Number(data.node.id);
|
||||||
appState.selectedFolderName = appState.folders.find((folder) => folder.id === appState.selectedFolderId)?.name ?? data.node.text;
|
appState.selectedFolderName = appState.folders.find((folder) => folder.id === appState.selectedFolderId)?.name ?? data.node.text;
|
||||||
appState.page = 1;
|
appState.page = 1;
|
||||||
@@ -118,8 +126,10 @@ export async function loadFolders() {
|
|||||||
|
|
||||||
if (appState.selectedFolderId) {
|
if (appState.selectedFolderId) {
|
||||||
$("#folderTree").on("ready.jstree", () => {
|
$("#folderTree").on("ready.jstree", () => {
|
||||||
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId));
|
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId), true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await loadModels();
|
if (reloadModels) {
|
||||||
|
await loadModels();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,63 @@ import { renderPagination } from "../../../components/pagination";
|
|||||||
import { getCurrentUser } from "../../../services/authState";
|
import { getCurrentUser } from "../../../services/authState";
|
||||||
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
|
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
|
||||||
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
||||||
|
import { downloadFileFromUrl } from "../../../utils/fileDownload";
|
||||||
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
||||||
|
import { resolveStorageUrl } from "../../../config/runtime";
|
||||||
import { appState } from "../appState";
|
import { appState } from "../appState";
|
||||||
|
import { generateModelThumbnailFromFile } from "./thumbnail";
|
||||||
|
|
||||||
type UploadFormState = {
|
type UploadFormState = {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
|
files: File[];
|
||||||
|
thumbnailFailures: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UploadModelPayload = {
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
fileName?: string;
|
||||||
|
folderId?: number;
|
||||||
|
brandName?: string;
|
||||||
|
typeName?: string;
|
||||||
|
operationTree?: string;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadModelResponse = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||||
|
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
|
||||||
|
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
|
||||||
|
const sceneModelErrorMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL_ERROR";
|
||||||
|
|
||||||
|
type SceneModelMessagePayload = {
|
||||||
|
modelName?: string;
|
||||||
|
fileName?: string;
|
||||||
|
modelBuffer?: ArrayBuffer;
|
||||||
|
operationTreeJson?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SceneModelMessage = {
|
||||||
|
type?: string;
|
||||||
|
requestId?: string;
|
||||||
|
payload?: SceneModelMessagePayload;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dictionariesLoaded = false;
|
||||||
|
let dictionariesLoading: Promise<void> | null = null;
|
||||||
|
let sceneModelBridgeBound = false;
|
||||||
|
let pendingSceneModelRequestId = "";
|
||||||
|
let pendingSceneModelRequestTimer: number | null = null;
|
||||||
|
let visibleModels = new Map<number, ModelItem>();
|
||||||
|
|
||||||
export function bindModelActions() {
|
export function bindModelActions() {
|
||||||
const isAdmin = getCurrentUser()?.role === "admin";
|
const isAdmin = getCurrentUser()?.role === "admin";
|
||||||
|
bindSceneModelBridge();
|
||||||
|
ensureImportSceneModelButton();
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
document.querySelector("#manageUsersBtn")?.addEventListener("click", async () => {
|
document.querySelector("#manageUsersBtn")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -25,21 +73,33 @@ export function bindModelActions() {
|
|||||||
document.querySelector("#manageDictionariesBtn")?.addEventListener("click", async () => {
|
document.querySelector("#manageDictionariesBtn")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const { openDictionaryManager } = await import("./dictionaries");
|
const { openDictionaryManager } = await import("./dictionaries");
|
||||||
await openDictionaryManager(loadModels);
|
await openDictionaryManager(async () => {
|
||||||
|
invalidateDictionaries();
|
||||||
|
await loadModels();
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error);
|
notifyError(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await openUploadModelDialog();
|
|
||||||
} catch (error) {
|
|
||||||
notifyError(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await openUploadModelDialog();
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#addProcessModelBtn")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await openProcessModelUploadDialog();
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector<HTMLSelectElement>("#brandFilter")!.addEventListener("change", async (event) => {
|
document.querySelector<HTMLSelectElement>("#brandFilter")!.addEventListener("change", async (event) => {
|
||||||
appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value;
|
appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value;
|
||||||
appState.page = 1;
|
appState.page = 1;
|
||||||
@@ -73,10 +133,11 @@ export function bindModelActions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadModels() {
|
export async function loadModels(options: { preserveCards?: boolean } = {}) {
|
||||||
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
|
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
|
||||||
if (!grid || !appState.selectedFolderId) return;
|
if (!grid || !appState.selectedFolderId) return;
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
|
syncSelectedFolderActions();
|
||||||
document.querySelector("#folderCrumb")!.textContent = appState.selectedFolderName || "模型库";
|
document.querySelector("#folderCrumb")!.textContent = appState.selectedFolderName || "模型库";
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
folderId: String(appState.selectedFolderId),
|
folderId: String(appState.selectedFolderId),
|
||||||
@@ -102,35 +163,122 @@ export async function loadModels() {
|
|||||||
await loadModels();
|
await loadModels();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
|
visibleModels = new Map(result.items.map((item) => [item.id, item]));
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => {
|
renderModelGrid(grid, result.items, options.preserveCards);
|
||||||
const model = result.items.find((item) => item.id === Number(button.dataset.id));
|
bindModelGridActions(grid);
|
||||||
if (model) {
|
}
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
|
function bindModelGridActions(grid: HTMLDivElement) {
|
||||||
|
if (grid.dataset.actionsBound === "true") return;
|
||||||
|
grid.dataset.actionsBound = "true";
|
||||||
|
grid.addEventListener("click", async (event) => {
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
const button = target?.closest<HTMLButtonElement>("button[data-action][data-id]");
|
||||||
|
if (!button || !grid.contains(button)) return;
|
||||||
|
const id = Number(button.dataset.id);
|
||||||
|
const model = visibleModels.get(id);
|
||||||
|
try {
|
||||||
|
switch (button.dataset.action) {
|
||||||
|
case "preview": {
|
||||||
|
if (!model) return;
|
||||||
const { openModelPreview } = await import("./preview");
|
const { openModelPreview } = await import("./preview");
|
||||||
openModelPreview(model, loadModels);
|
openModelPreview(model, loadModels);
|
||||||
});
|
break;
|
||||||
|
}
|
||||||
|
case "edit":
|
||||||
|
await editModel(id);
|
||||||
|
break;
|
||||||
|
case "import": {
|
||||||
|
if (!model) return;
|
||||||
|
const { createImportScenePayload, emitImportScenePayload } = await import("./preview");
|
||||||
|
const payload = createImportScenePayload({
|
||||||
|
model,
|
||||||
|
basePointEnabled: false,
|
||||||
|
basePoint: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 },
|
||||||
|
selectedOperationIndex: null
|
||||||
|
});
|
||||||
|
emitImportScenePayload(payload);
|
||||||
|
notify("导入场景数据已输出");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "download":
|
||||||
|
if (!model) return;
|
||||||
|
await downloadFileFromUrl(model.file_url, model.original_filename || model.name);
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await deleteModel(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
|
|
||||||
});
|
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
notify("导入功能预留,后续接入当前模型的导入逻辑");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDictionaries() {
|
function renderModelGrid(grid: HTMLDivElement, items: ModelItem[], preserveCards = false) {
|
||||||
const result = await api<DictionaryResponse>("/api/dictionaries");
|
if (!preserveCards) {
|
||||||
appState.brands = result.brands;
|
grid.innerHTML = items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
|
||||||
appState.types = result.types;
|
return;
|
||||||
syncDictionarySelect("#brandFilter", result.brands, "全部品牌", appState.filters.brandId);
|
}
|
||||||
syncDictionarySelect("#typeFilter", result.types, "全部类型", appState.filters.typeId);
|
if (items.length === 0) {
|
||||||
|
grid.innerHTML = `<div class="empty-state">当前目录暂无模型</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingCards = new Map(
|
||||||
|
Array.from(grid.querySelectorAll<HTMLElement>(".model-card[data-model-id]"))
|
||||||
|
.map((card) => [Number(card.dataset.modelId), card])
|
||||||
|
);
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const item of items) {
|
||||||
|
const existingCard = existingCards.get(item.id);
|
||||||
|
if (existingCard) {
|
||||||
|
fragment.append(existingCard);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const template = document.createElement("template");
|
||||||
|
template.innerHTML = renderModelCard(item).trim();
|
||||||
|
fragment.append(template.content);
|
||||||
|
}
|
||||||
|
grid.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadFoldersAndModels(preserveModelCards = false) {
|
||||||
|
const { loadFolders } = await import("./folders");
|
||||||
|
await loadFolders({ reloadModels: false });
|
||||||
|
await loadModels({ preserveCards: preserveModelCards });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDictionaries(force = false) {
|
||||||
|
if (!force && dictionariesLoaded) {
|
||||||
|
syncDictionaryFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && dictionariesLoading) {
|
||||||
|
await dictionariesLoading;
|
||||||
|
syncDictionaryFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictionariesLoading = api<DictionaryResponse>("/api/dictionaries")
|
||||||
|
.then((result) => {
|
||||||
|
appState.brands = result.brands;
|
||||||
|
appState.types = result.types;
|
||||||
|
dictionariesLoaded = true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dictionariesLoading = null;
|
||||||
|
});
|
||||||
|
await dictionariesLoading;
|
||||||
|
syncDictionaryFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDictionaries() {
|
||||||
|
dictionariesLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDictionaryFilters() {
|
||||||
|
syncDictionarySelect("#brandFilter", appState.brands, "全部品牌", appState.filters.brandId);
|
||||||
|
syncDictionarySelect("#typeFilter", appState.types, "全部类型", appState.filters.typeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDictionarySelect(selector: string, items: { id: number; name: string }[], emptyText: string, value: string) {
|
function syncDictionarySelect(selector: string, items: { id: number; name: string }[], emptyText: string, value: string) {
|
||||||
@@ -144,11 +292,11 @@ function syncDictionarySelect(selector: string, items: { id: number; name: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderModelCard(item: ModelItem) {
|
function renderModelCard(item: ModelItem) {
|
||||||
const isAdmin = getCurrentUser()?.role === "admin";
|
const canWrite = item.permissions.write;
|
||||||
const prop = item.properties ?? {};
|
const prop = item.properties ?? {};
|
||||||
const thumb = item.thumbnail_url
|
const thumb = item.thumbnail_url
|
||||||
? `<img src="${item.thumbnail_url}" alt="" />`
|
? `<img src="${resolveStorageUrl(item.thumbnail_url)}" alt="" />`
|
||||||
: `<div class="thumb-placeholder">GLB</div>`;
|
: `<div class="thumb-placeholder">暂无预览图</div>`;
|
||||||
return `
|
return `
|
||||||
<article class="model-card" data-model-id="${item.id}">
|
<article class="model-card" data-model-id="${item.id}">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
@@ -158,42 +306,204 @@ function renderModelCard(item: ModelItem) {
|
|||||||
${thumb}
|
${thumb}
|
||||||
<div class="thumb-actions">
|
<div class="thumb-actions">
|
||||||
<button data-action="preview" data-id="${item.id}">预览</button>
|
<button data-action="preview" data-id="${item.id}">预览</button>
|
||||||
${isAdmin ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
<button data-action="download" data-id="${item.id}">下载</button>
|
||||||
|
${canWrite ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
||||||
<button data-action="import" data-id="${item.id}">导入</button>
|
<button data-action="import" data-id="${item.id}">导入</button>
|
||||||
${isAdmin ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
${canWrite ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
|
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
|
||||||
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
|
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
|
||||||
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
|
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
|
||||||
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
|
|
||||||
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSelectedFolderActions() {
|
||||||
|
const currentFolder = appState.folders.find((folder) => folder.id === appState.selectedFolderId);
|
||||||
|
const canWrite = Boolean(currentFolder?.permissions.write);
|
||||||
|
const addModelBtn = document.querySelector<HTMLButtonElement>("#addModelBtn");
|
||||||
|
const addProcessModelBtn = document.querySelector<HTMLButtonElement>("#addProcessModelBtn");
|
||||||
|
const importSceneModelBtn = document.querySelector<HTMLButtonElement>("#importSceneModelBtn");
|
||||||
|
if (addModelBtn) addModelBtn.hidden = !canWrite;
|
||||||
|
if (addProcessModelBtn) addProcessModelBtn.hidden = !canWrite;
|
||||||
|
if (importSceneModelBtn) importSceneModelBtn.hidden = !canWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureImportSceneModelButton() {
|
||||||
|
if (document.querySelector("#importSceneModelBtn")) return;
|
||||||
|
const addModelBtn = document.querySelector<HTMLButtonElement>("#addModelBtn");
|
||||||
|
if (!addModelBtn) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.id = "importSceneModelBtn";
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "导入场景模型";
|
||||||
|
button.addEventListener("click", requestSceneModelImport);
|
||||||
|
addModelBtn.insertAdjacentElement("afterend", button);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSceneModelBridge() {
|
||||||
|
if (sceneModelBridgeBound) return;
|
||||||
|
sceneModelBridgeBound = true;
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const data = event.data as SceneModelMessage;
|
||||||
|
if (data?.type !== sceneModelMessageType && data?.type !== sceneModelErrorMessageType) return;
|
||||||
|
if (!isAllowedParentOrigin(event.origin)) return;
|
||||||
|
void handleSceneModelMessage(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSceneModelImport() {
|
||||||
|
if (!appState.selectedFolderId) {
|
||||||
|
notify("请先选择目录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.parent || window.parent === window) {
|
||||||
|
notify("请在 DMT 主程序中使用导入场景模型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = createSceneModelRequestId();
|
||||||
|
pendingSceneModelRequestId = requestId;
|
||||||
|
clearSceneModelRequestTimer();
|
||||||
|
pendingSceneModelRequestTimer = window.setTimeout(() => {
|
||||||
|
if (pendingSceneModelRequestId !== requestId) return;
|
||||||
|
pendingSceneModelRequestId = "";
|
||||||
|
pendingSceneModelRequestTimer = null;
|
||||||
|
notifyError("主程序未响应导入场景模型请求,请确认模型库是在主程序面板中打开");
|
||||||
|
}, 15000);
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: requestSceneModelMessageType,
|
||||||
|
requestId
|
||||||
|
}, resolveParentPostMessageOrigin());
|
||||||
|
// notify("已请求主程序导出当前选中模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSceneModelMessage(data: SceneModelMessage) {
|
||||||
|
console.log("Received scene model message:", data);
|
||||||
|
if (pendingSceneModelRequestId && data.requestId && data.requestId !== pendingSceneModelRequestId) return;
|
||||||
|
pendingSceneModelRequestId = "";
|
||||||
|
clearSceneModelRequestTimer();
|
||||||
|
if (data.type === sceneModelErrorMessageType) {
|
||||||
|
notifyError(data.message || "主程序导出模型失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = data.payload;
|
||||||
|
if (!payload?.modelBuffer) {
|
||||||
|
notifyError("主程序返回的模型数据为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = ensureGlbFileName(payload.fileName || payload.modelName || "scene-model.glb");
|
||||||
|
const file = new File([payload.modelBuffer], fileName, { type: "model/gltf-binary" });
|
||||||
|
await openProcessModelUploadDialog({
|
||||||
|
file,
|
||||||
|
modelName: payload.modelName || modelNameFromFile(fileName),
|
||||||
|
operationTreeJson: payload.operationTreeJson || defaultOperationTree
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSceneModelRequestTimer() {
|
||||||
|
if (pendingSceneModelRequestTimer === null) return;
|
||||||
|
window.clearTimeout(pendingSceneModelRequestTimer);
|
||||||
|
pendingSceneModelRequestTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSceneModelRequestId() {
|
||||||
|
return `scene-model-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedParentOrigin(origin: string) {
|
||||||
|
const expectedOrigin = resolveParentOrigin();
|
||||||
|
return expectedOrigin === "*" || origin === expectedOrigin || isLocalDmtParentOrigin(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentPostMessageOrigin() {
|
||||||
|
const origin = resolveParentOrigin();
|
||||||
|
return origin === window.location.origin ? "*" : origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentOrigin() {
|
||||||
|
try {
|
||||||
|
const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> };
|
||||||
|
const configuredOrigin = meta.env?.VITE_DMT_PARENT_ORIGIN;
|
||||||
|
if (configuredOrigin) return configuredOrigin;
|
||||||
|
if (document.referrer) return new URL(document.referrer).origin;
|
||||||
|
} catch {
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalDmtParentOrigin(origin: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const isLocalHost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||||
|
return isLocalHost && url.port === "3000";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGlbFileName(fileName: string) {
|
||||||
|
return fileName.toLowerCase().endsWith(".glb") ? fileName : `${fileName}.glb`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadModelToBackend(payload: UploadModelPayload) {
|
||||||
|
const fileName = (payload.fileName || payload.file.name || payload.name).trim();
|
||||||
|
const displayName = (payload.name || modelNameFromFile(fileName)).trim();
|
||||||
|
if (!displayName) {
|
||||||
|
throw new Error("模型名称不能为空");
|
||||||
|
}
|
||||||
|
if (!fileName.toLowerCase().endsWith(".glb")) {
|
||||||
|
throw new Error("当前阶段只允许上传 .glb 模型");
|
||||||
|
}
|
||||||
|
const folderId = payload.folderId ?? appState.selectedFolderId;
|
||||||
|
if (!folderId) {
|
||||||
|
throw new Error("请先选择目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("folderId", String(folderId));
|
||||||
|
form.set("name", displayName);
|
||||||
|
form.set("brandName", payload.brandName ?? "");
|
||||||
|
form.set("typeName", payload.typeName ?? "");
|
||||||
|
form.set("operationTree", normalizeOperationTreeInput(payload.operationTree));
|
||||||
|
form.set("file", payload.file, fileName);
|
||||||
|
for (const [key, value] of Object.entries(payload.properties ?? {})) {
|
||||||
|
form.set(`prop.${key}`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api<UploadModelResponse>("/api/models/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
return result.id;
|
||||||
|
}
|
||||||
|
|
||||||
async function openUploadModelDialog() {
|
async function openUploadModelDialog() {
|
||||||
if (!appState.selectedFolderId) {
|
if (!appState.selectedFolderId) {
|
||||||
notify("请先选择目录");
|
notify("请先选择目录");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: UploadFormState = { file: null };
|
const state: UploadFormState = { file: null, files: [], thumbnailFailures: [] };
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
const result = await formDialog<boolean>({
|
const result = await formDialog<boolean>({
|
||||||
title: "增加模型",
|
title: "增加模型",
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 520,
|
height: 430,
|
||||||
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
|
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
|
||||||
<label>
|
<label>
|
||||||
<span>模型文件</span>
|
<span>模型文件</span>
|
||||||
<div id="modelDropZone" class="drop-zone">
|
<div id="modelDropZone" class="drop-zone">
|
||||||
<input id="modelUploadFile" type="file" accept=".glb" />
|
<input id="modelUploadFile" type="file" accept=".glb" multiple />
|
||||||
<strong>选择模型</strong>
|
<strong>选择模型</strong>
|
||||||
<em>或拖拽 .glb 模型到这里</em>
|
<em>或拖拽一个或多个 .glb 模型到这里</em>
|
||||||
<small id="selectedFileName">未选择文件</small>
|
<small id="selectedFileName">未选择文件</small>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -205,41 +515,157 @@ async function openUploadModelDialog() {
|
|||||||
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
||||||
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
||||||
<label><span>型号</span><input name="model" /></label>
|
<label><span>型号</span><input name="model" /></label>
|
||||||
<label><span>价钱</span><input name="price" /></label>
|
|
||||||
<label><span>重量</span><input name="weight" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modelUploadStatus" class="upload-status" aria-live="polite" hidden></div>
|
||||||
${dictionaryDatalistHtml()}
|
${dictionaryDatalistHtml()}
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
onOpen: () => bindUploadDialogEvents(state),
|
onOpen: () => bindUploadDialogEvents(state),
|
||||||
onSubmit: async () => {
|
onSubmit: async () => {
|
||||||
if (!state.file) {
|
if (state.files.length === 0) {
|
||||||
throw new Error("请选择 .glb 模型文件");
|
throw new Error("请选择 .glb 模型文件");
|
||||||
}
|
}
|
||||||
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
||||||
if (!name) {
|
if (state.files.length === 1 && !name) {
|
||||||
throw new Error("模型名称不能为空");
|
throw new Error("模型名称不能为空");
|
||||||
}
|
}
|
||||||
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
||||||
const payload = new FormData();
|
const brandName = String(form.get("brandName") ?? "");
|
||||||
payload.set("folderId", String(appState.selectedFolderId));
|
const typeName = String(form.get("typeName") ?? "");
|
||||||
payload.set("name", name);
|
const properties = {
|
||||||
payload.set("brandName", String(form.get("brandName") ?? ""));
|
model: String(form.get("model") ?? "")
|
||||||
payload.set("typeName", String(form.get("typeName") ?? ""));
|
};
|
||||||
payload.set("file", state.file);
|
let completedCount = 0;
|
||||||
for (const key of ["model", "price", "weight"]) {
|
const totalCount = state.files.length;
|
||||||
payload.set(`prop.${key}`, String(form.get(key) ?? ""));
|
setUploadDialogBusy(state, true);
|
||||||
|
showUploadProgressOverlay(totalCount);
|
||||||
|
try {
|
||||||
|
for (const [index, file] of state.files.entries()) {
|
||||||
|
const uploadingMessage = `正在上传 ${index + 1}/${totalCount}:${file.name}`;
|
||||||
|
updateUploadStatus(uploadingMessage);
|
||||||
|
updateUploadProgressOverlay({
|
||||||
|
total: totalCount,
|
||||||
|
completed: completedCount,
|
||||||
|
message: uploadingMessage,
|
||||||
|
fileName: file.name
|
||||||
|
});
|
||||||
|
await waitForUploadStatusPaint();
|
||||||
|
const modelId = await uploadModelToBackend({
|
||||||
|
file,
|
||||||
|
name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name),
|
||||||
|
fileName: file.name,
|
||||||
|
brandName,
|
||||||
|
typeName,
|
||||||
|
operationTree: defaultOperationTree,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
const thumbnailMessage = `正在生成预览图 ${index + 1}/${totalCount}:${file.name}`;
|
||||||
|
updateUploadStatus(thumbnailMessage);
|
||||||
|
updateUploadProgressOverlay({
|
||||||
|
total: totalCount,
|
||||||
|
completed: completedCount,
|
||||||
|
message: thumbnailMessage,
|
||||||
|
fileName: file.name
|
||||||
|
});
|
||||||
|
await waitForUploadStatusPaint();
|
||||||
|
try {
|
||||||
|
await generateAndSaveModelThumbnail(modelId, file);
|
||||||
|
} catch {
|
||||||
|
state.thumbnailFailures.push(file.name);
|
||||||
|
}
|
||||||
|
completedCount += 1;
|
||||||
|
const completedMessage = `已完成 ${completedCount}/${totalCount}`;
|
||||||
|
updateUploadStatus(completedMessage);
|
||||||
|
updateUploadProgressOverlay({
|
||||||
|
total: totalCount,
|
||||||
|
completed: completedCount,
|
||||||
|
message: completedMessage,
|
||||||
|
fileName: file.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateUploadStatus(`上传失败,已完成 ${completedCount}/${totalCount},请处理后重试。`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
hideUploadProgressOverlay();
|
||||||
|
if (completedCount < state.files.length) {
|
||||||
|
setUploadDialogBusy(state, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await api("/api/models/upload", {
|
|
||||||
method: "POST",
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
await loadModels();
|
if (state.thumbnailFailures.length > 0) {
|
||||||
|
notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`);
|
||||||
|
} else {
|
||||||
|
notify("模型上传和预览图生成完成");
|
||||||
|
}
|
||||||
|
invalidateDictionaries();
|
||||||
|
await reloadFoldersAndModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProcessModelUploadDialog(input?: {
|
||||||
|
file?: File | null;
|
||||||
|
modelName?: string;
|
||||||
|
operationTreeJson?: string;
|
||||||
|
}) {
|
||||||
|
if (!appState.selectedFolderId) {
|
||||||
|
notify("请先选择目录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: UploadFormState = { file: input?.file ?? null, files: input?.file ? [input.file] : [], thumbnailFailures: [] };
|
||||||
|
await loadDictionaries();
|
||||||
|
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
|
||||||
|
const result = await formDialog<boolean>({
|
||||||
|
title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}】`,
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
blockPage: false,
|
||||||
|
body: `
|
||||||
|
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form">
|
||||||
|
<div class="popup-form-grid">
|
||||||
|
<label><span>模型名称</span><input id="processModelUploadName" name="name" value="${escapeHtml(initialName)}" autocomplete="off" /></label>
|
||||||
|
<label><span>品牌</span><input name="brandName" list="brandOptions" autocomplete="off" /></label>
|
||||||
|
<label><span>类型</span><input name="typeName" list="typeOptions" autocomplete="off" /></label>
|
||||||
|
<label><span>型号</span><input name="model" autocomplete="off" /></label>
|
||||||
|
</div>
|
||||||
|
${dictionaryDatalistHtml()}
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
onOpen: () => bindProcessUploadDialogEvents(state),
|
||||||
|
onSubmit: async () => {
|
||||||
|
if (!state.file) {
|
||||||
|
throw new Error("请选择 .glb 模型文件");
|
||||||
|
}
|
||||||
|
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
|
||||||
|
const name = String(form.get("name") ?? "").trim();
|
||||||
|
const modelId = await uploadModelToBackend({
|
||||||
|
file: state.file,
|
||||||
|
name,
|
||||||
|
fileName: state.file.name,
|
||||||
|
brandName: String(form.get("brandName") ?? ""),
|
||||||
|
typeName: String(form.get("typeName") ?? ""),
|
||||||
|
operationTree: String(form.get("operationTree") ?? ""),
|
||||||
|
properties: {
|
||||||
|
model: String(form.get("model") ?? "")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await generateAndSaveModelThumbnail(modelId, state.file);
|
||||||
|
} catch {
|
||||||
|
notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
invalidateDictionaries();
|
||||||
|
await reloadFoldersAndModels();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,21 +675,48 @@ function bindUploadDialogEvents(state: UploadFormState) {
|
|||||||
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
||||||
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
|
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
|
||||||
|
|
||||||
const selectFile = (file: File) => {
|
const renderSelectedFiles = () => {
|
||||||
if (!file.name.toLowerCase().endsWith(".glb")) {
|
if (state.files.length === 0) {
|
||||||
|
selectedFileName.textContent = "未选择文件";
|
||||||
|
nameInput.disabled = false;
|
||||||
|
nameInput.value = "";
|
||||||
|
nameInput.placeholder = "选择文件后自动填入";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.files.length === 1) {
|
||||||
|
const [file] = state.files;
|
||||||
|
selectedFileName.textContent = file.name;
|
||||||
|
nameInput.disabled = false;
|
||||||
|
nameInput.value = modelNameFromFile(file.name);
|
||||||
|
nameInput.placeholder = "选择文件后自动填入";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFileName.innerHTML = `
|
||||||
|
<span class="selected-file-summary">已选择 ${state.files.length} 个模型,名称按文件名自动生成</span>
|
||||||
|
<span class="selected-file-list">${state.files.map((file) => escapeHtml(file.name)).join("<br>")}</span>
|
||||||
|
`;
|
||||||
|
nameInput.disabled = true;
|
||||||
|
nameInput.value = `${state.files.length} 个模型按文件名自动命名`;
|
||||||
|
nameInput.placeholder = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFiles = (files: FileList | File[]) => {
|
||||||
|
const selectedFiles = Array.from(files);
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
const invalidFiles = selectedFiles.filter((file) => !file.name.toLowerCase().endsWith(".glb"));
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
notify("当前阶段只允许上传 .glb 模型");
|
notify("当前阶段只允许上传 .glb 模型");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.file = file;
|
state.files = selectedFiles;
|
||||||
selectedFileName.textContent = file.name;
|
state.file = selectedFiles[0] ?? null;
|
||||||
if (!nameInput.value.trim()) {
|
renderSelectedFiles();
|
||||||
nameInput.value = modelNameFromFile(file.name);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fileInput.addEventListener("change", () => {
|
fileInput.addEventListener("change", () => {
|
||||||
const file = fileInput.files?.[0];
|
if (fileInput.files) selectFiles(fileInput.files);
|
||||||
if (file) selectFile(file);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener("click", (event) => {
|
dropZone.addEventListener("click", (event) => {
|
||||||
@@ -280,19 +733,198 @@ function bindUploadDialogEvents(state: UploadFormState) {
|
|||||||
dropZone.addEventListener("drop", (event) => {
|
dropZone.addEventListener("drop", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dropZone.classList.remove("is-dragover");
|
dropZone.classList.remove("is-dragover");
|
||||||
const file = event.dataTransfer?.files?.[0];
|
const files = event.dataTransfer?.files;
|
||||||
if (file) selectFile(file);
|
if (files) selectFiles(files);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUploadStatus(message: string) {
|
||||||
|
const status = document.querySelector<HTMLElement>("#modelUploadStatus");
|
||||||
|
if (!status) return;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForUploadStatusPaint() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadProgressOverlay(total: number) {
|
||||||
|
let overlay = document.querySelector<HTMLElement>("#modelUploadProgressOverlay");
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement("div");
|
||||||
|
overlay.id = "modelUploadProgressOverlay";
|
||||||
|
overlay.className = "upload-progress-overlay";
|
||||||
|
overlay.setAttribute("role", "status");
|
||||||
|
overlay.setAttribute("aria-live", "polite");
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="upload-progress-panel">
|
||||||
|
<div class="upload-progress-title">模型上传中</div>
|
||||||
|
<div id="modelUploadProgressMessage" class="upload-progress-message">正在准备上传...</div>
|
||||||
|
<div class="upload-progress-track">
|
||||||
|
<div id="modelUploadProgressBar" class="upload-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="modelUploadProgressMeta" class="upload-progress-meta">0/${total}</div>
|
||||||
|
<div id="modelUploadProgressFile" class="upload-progress-file"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.hidden = false;
|
||||||
|
updateUploadProgressOverlay({
|
||||||
|
total,
|
||||||
|
completed: 0,
|
||||||
|
message: "正在准备上传...",
|
||||||
|
fileName: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadProgressOverlay(options: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
message: string;
|
||||||
|
fileName: string;
|
||||||
|
}) {
|
||||||
|
const message = document.querySelector<HTMLElement>("#modelUploadProgressMessage");
|
||||||
|
const meta = document.querySelector<HTMLElement>("#modelUploadProgressMeta");
|
||||||
|
const file = document.querySelector<HTMLElement>("#modelUploadProgressFile");
|
||||||
|
const bar = document.querySelector<HTMLElement>("#modelUploadProgressBar");
|
||||||
|
const percent = options.total > 0 ? Math.round((options.completed / options.total) * 100) : 0;
|
||||||
|
if (message) message.textContent = options.message;
|
||||||
|
if (meta) meta.textContent = `${options.completed}/${options.total}`;
|
||||||
|
if (file) file.textContent = options.fileName;
|
||||||
|
if (bar) bar.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUploadProgressOverlay() {
|
||||||
|
const overlay = document.querySelector<HTMLElement>("#modelUploadProgressOverlay");
|
||||||
|
if (overlay) overlay.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAndSaveModelThumbnail(modelId: number, file: File) {
|
||||||
|
const thumbnail = await generateModelThumbnailFromFile(file);
|
||||||
|
await api(`/api/models/${modelId}/thumbnail`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ thumbnail })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadDialogBusy(state: UploadFormState, busy: boolean) {
|
||||||
|
const form = document.querySelector<HTMLFormElement>("#modelUploadPopupForm");
|
||||||
|
if (!form) return;
|
||||||
|
form.classList.toggle("is-uploading", busy);
|
||||||
|
form.querySelectorAll<HTMLInputElement>("input").forEach((input) => {
|
||||||
|
input.disabled = busy || (input.id === "modelUploadName" && state.files.length > 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindProcessUploadDialogEvents(state: UploadFormState) {
|
||||||
|
// const dropZone = document.querySelector<HTMLDivElement>("#processModelDropZone")!;
|
||||||
|
// const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!;
|
||||||
|
// const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!;
|
||||||
|
// const selectedFileName = document.querySelector<HTMLElement>("#processSelectedFileName")!;
|
||||||
|
|
||||||
|
// const selectFile = (file: File) => {
|
||||||
|
// if (!file.name.toLowerCase().endsWith(".glb")) {
|
||||||
|
// notify("当前阶段只允许上传 .glb 模型");
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// state.file = file;
|
||||||
|
// selectedFileName.textContent = file.name;
|
||||||
|
// if (!nameInput.value.trim()) {
|
||||||
|
// nameInput.value = file.name;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// if (state.file) {
|
||||||
|
// selectedFileName.textContent = state.file.name;
|
||||||
|
// if (!nameInput.value.trim()) {
|
||||||
|
// nameInput.value = state.file.name;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fileInput.addEventListener("change", () => {
|
||||||
|
// const file = fileInput.files?.[0];
|
||||||
|
// if (file) selectFile(file);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// dropZone.addEventListener("click", (event) => {
|
||||||
|
// if (event.target !== fileInput) fileInput.click();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// dropZone.addEventListener("dragover", (event) => {
|
||||||
|
// event.preventDefault();
|
||||||
|
// dropZone.classList.add("is-dragover");
|
||||||
|
// });
|
||||||
|
// dropZone.addEventListener("dragleave", () => {
|
||||||
|
// dropZone.classList.remove("is-dragover");
|
||||||
|
// });
|
||||||
|
// dropZone.addEventListener("drop", (event) => {
|
||||||
|
// event.preventDefault();
|
||||||
|
// dropZone.classList.remove("is-dragover");
|
||||||
|
// const file = event.dataTransfer?.files?.[0];
|
||||||
|
// if (file) selectFile(file);
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOperationTreeInput(value?: string) {
|
||||||
|
const clean = value?.trim();
|
||||||
|
if (!clean) return defaultOperationTree;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(clean);
|
||||||
|
} catch {
|
||||||
|
throw new Error("工艺数据必须是合法 JSON 字符串");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return JSON.stringify({ OperationTree: JSON.stringify(parsed) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === "object" && "OperationTree" in parsed) {
|
||||||
|
const normalized = { ...(parsed as Record<string, unknown>) };
|
||||||
|
const operationTree = normalized.OperationTree;
|
||||||
|
if (typeof operationTree === "string") {
|
||||||
|
JSON.parse(operationTree);
|
||||||
|
normalized.OperationTree = operationTree;
|
||||||
|
normalizeOptionalJsonListField(normalized, "ModelProcess");
|
||||||
|
normalizeOptionalJsonListField(normalized, "ProcesslLabelList");
|
||||||
|
return JSON.stringify(normalized);
|
||||||
|
}
|
||||||
|
if (Array.isArray(operationTree)) {
|
||||||
|
normalized.OperationTree = JSON.stringify(operationTree);
|
||||||
|
normalizeOptionalJsonListField(normalized, "ModelProcess");
|
||||||
|
normalizeOptionalJsonListField(normalized, "ProcesslLabelList");
|
||||||
|
return JSON.stringify(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('工艺数据格式必须是 {"OperationTree":"[...]"} 或 OperationTree[] 数组');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalJsonListField(target: Record<string, unknown>, field: string) {
|
||||||
|
const value = target[field];
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
JSON.parse(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
target[field] = JSON.stringify(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function editModel(id: number) {
|
async function editModel(id: number) {
|
||||||
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
|
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
|
||||||
const oldName = card?.querySelector("strong")?.textContent ?? "";
|
const oldName = card?.querySelector("strong")?.textContent ?? "";
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string; price: string; weight: string }>({
|
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string }>({
|
||||||
title: "编辑模型",
|
title: "编辑模型",
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 390,
|
height: 390,
|
||||||
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<form id="modelEditPopupForm" class="popup-form">
|
<form id="modelEditPopupForm" class="popup-form">
|
||||||
<label><span>模型名称</span><input name="name" value="${escapeHtml(oldName)}" /></label>
|
<label><span>模型名称</span><input name="name" value="${escapeHtml(oldName)}" /></label>
|
||||||
@@ -300,8 +932,6 @@ async function editModel(id: number) {
|
|||||||
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
|
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
|
||||||
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
|
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
|
||||||
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
|
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
|
||||||
<label><span>价钱</span><input name="price" value="${escapeHtml(card?.querySelector("dl div:nth-child(4) dd")?.textContent ?? "")}" /></label>
|
|
||||||
<label><span>重量</span><input name="weight" value="${escapeHtml(card?.querySelector("dl div:nth-child(5) dd")?.textContent ?? "")}" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
${dictionaryDatalistHtml()}
|
${dictionaryDatalistHtml()}
|
||||||
</form>
|
</form>
|
||||||
@@ -316,9 +946,7 @@ async function editModel(id: number) {
|
|||||||
name,
|
name,
|
||||||
brandName: String(form.get("brandName") ?? ""),
|
brandName: String(form.get("brandName") ?? ""),
|
||||||
typeName: String(form.get("typeName") ?? ""),
|
typeName: String(form.get("typeName") ?? ""),
|
||||||
model: String(form.get("model") ?? ""),
|
model: String(form.get("model") ?? "")
|
||||||
price: String(form.get("price") ?? ""),
|
|
||||||
weight: String(form.get("weight") ?? "")
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -330,12 +958,11 @@ async function editModel(id: number) {
|
|||||||
brandName: result.brandName,
|
brandName: result.brandName,
|
||||||
typeName: result.typeName,
|
typeName: result.typeName,
|
||||||
properties: {
|
properties: {
|
||||||
model: result.model,
|
model: result.model
|
||||||
price: result.price,
|
|
||||||
weight: result.weight
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
invalidateDictionaries();
|
||||||
await loadModels();
|
await loadModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,5 +981,5 @@ async function deleteModel(id: number) {
|
|||||||
const confirmed = await confirmDialog("确认删除该模型?");
|
const confirmed = await confirmDialog("确认删除该模型?");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
await api(`/api/models/${id}`, { method: "DELETE" });
|
await api(`/api/models/${id}`, { method: "DELETE" });
|
||||||
await loadModels();
|
await reloadFoldersAndModels(true);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
96
web/src/pages/app/modules/thumbnail.ts
Normal file
96
web/src/pages/app/modules/thumbnail.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Material, Object3D, PerspectiveCamera, Texture } from "three";
|
||||||
|
import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH = 512;
|
||||||
|
const THUMBNAIL_HEIGHT = 384;
|
||||||
|
|
||||||
|
type ThreeModule = typeof import("three");
|
||||||
|
type LoadedGltf = Awaited<ReturnType<GLTFLoader["loadAsync"]>>;
|
||||||
|
type ParseableGltfLoader = GLTFLoader & {
|
||||||
|
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateModelThumbnailFromFile(file: File) {
|
||||||
|
const [THREE, { GLTFLoader }] = await Promise.all([
|
||||||
|
import("three"),
|
||||||
|
import("three/examples/jsm/loaders/GLTFLoader.js")
|
||||||
|
]);
|
||||||
|
|
||||||
|
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.01, 1000);
|
||||||
|
camera.up.set(0, 0, 1);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
preserveDrawingBuffer: true
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
renderer.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, false);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
const ambientLight = new THREE.HemisphereLight(0xffffff, 0x9aa8b5, 2.2);
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.8);
|
||||||
|
keyLight.position.set(3, -4, 5);
|
||||||
|
scene.add(ambientLight, keyLight);
|
||||||
|
|
||||||
|
let object: Object3D | null = null;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const gltf = await (new GLTFLoader() as ParseableGltfLoader).parseAsync(buffer, "");
|
||||||
|
if (!gltf.scene) {
|
||||||
|
throw new Error("模型文件中未找到可渲染场景");
|
||||||
|
}
|
||||||
|
object = gltf.scene;
|
||||||
|
scene.add(object);
|
||||||
|
fitCameraToObject(THREE, camera, object);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
return renderer.domElement.toDataURL("image/png");
|
||||||
|
} finally {
|
||||||
|
if (object) disposeObject3D(object);
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.forceContextLoss();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, object: Object3D) {
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||||||
|
const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));
|
||||||
|
|
||||||
|
camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
|
||||||
|
camera.near = Math.max(distance / 100, 0.01);
|
||||||
|
camera.far = distance * 100;
|
||||||
|
camera.lookAt(center);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject3D(root: Object3D) {
|
||||||
|
root.traverse((object) => {
|
||||||
|
const maybeMesh = object as Object3D & {
|
||||||
|
geometry?: { dispose?: () => void };
|
||||||
|
material?: Material | Material[];
|
||||||
|
};
|
||||||
|
maybeMesh.geometry?.dispose?.();
|
||||||
|
const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : maybeMesh.material ? [maybeMesh.material] : [];
|
||||||
|
for (const material of materials) {
|
||||||
|
disposeMaterial(material);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeMaterial(material: Material) {
|
||||||
|
const values = Object.values(material as Material & Record<string, unknown>);
|
||||||
|
for (const value of values) {
|
||||||
|
if (value && typeof value === "object" && "isTexture" in value) {
|
||||||
|
(value as Texture).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export async function openUserManager() {
|
|||||||
title: "人员权限管理",
|
title: "人员权限管理",
|
||||||
width: 980,
|
width: 980,
|
||||||
height: 560,
|
height: 560,
|
||||||
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<div class="user-manager">
|
<div class="user-manager">
|
||||||
<header class="user-manager-head">
|
<header class="user-manager-head">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getToken } from "./authState";
|
import { getToken } from "./authState";
|
||||||
|
import { resolveApiUrl } from "../config/runtime";
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
function authHeaders(): Record<string, string> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -6,10 +7,11 @@ function authHeaders(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const hasBody = options.body !== undefined && options.body !== null;
|
||||||
|
const response = await fetch(resolveApiUrl(url), {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
|
||||||
...authHeaders(),
|
...authHeaders(),
|
||||||
...((options.headers as Record<string, string> | undefined) ?? {})
|
...((options.headers as Record<string, string> | undefined) ?? {})
|
||||||
} as HeadersInit
|
} as HeadersInit
|
||||||
@@ -20,4 +22,3 @@ export async function api<T>(url: string, options: RequestInit = {}): Promise<T>
|
|||||||
}
|
}
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||||
font-size: 13px;
|
|
||||||
color: #1c2733;
|
color: #1c2733;
|
||||||
background: #eef2f5;
|
background: #eef2f5;
|
||||||
}
|
}
|
||||||
@@ -8,7 +7,9 @@
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
font-size: 62.5%;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export type AuthUser = {
|
|||||||
|
|
||||||
export type UserRole = "admin" | "user";
|
export type UserRole = "admin" | "user";
|
||||||
|
|
||||||
|
export type AuthBootstrapResponse =
|
||||||
|
| { mode: "login" }
|
||||||
|
| { mode: "fixed"; token: string; user: AuthUser };
|
||||||
|
|
||||||
export type ManagedUser = {
|
export type ManagedUser = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -25,7 +29,17 @@ export type Folder = {
|
|||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
library_type: "system" | "user";
|
||||||
|
owner_user_id: number | null;
|
||||||
|
is_system: number;
|
||||||
|
owner_username?: string | null;
|
||||||
model_count: number;
|
model_count: number;
|
||||||
|
permissions: PermissionPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionPayload = {
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelItem = {
|
export type ModelItem = {
|
||||||
@@ -37,14 +51,17 @@ export type ModelItem = {
|
|||||||
original_filename: string;
|
original_filename: string;
|
||||||
file_path: string;
|
file_path: string;
|
||||||
file_url: string;
|
file_url: string;
|
||||||
|
library_type: "system" | "user";
|
||||||
|
owner_user_id: number | null;
|
||||||
storage_provider: "local" | "cos";
|
storage_provider: "local" | "cos";
|
||||||
file_size: number;
|
file_size: number;
|
||||||
thumbnail: string | null;
|
|
||||||
thumbnail_path: string | null;
|
thumbnail_path: string | null;
|
||||||
thumbnail_provider: "local" | "cos" | null;
|
thumbnail_provider: "local" | "cos" | null;
|
||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
|
operation_tree: string;
|
||||||
brand_name: string | null;
|
brand_name: string | null;
|
||||||
type_name: string | null;
|
type_name: string | null;
|
||||||
|
permissions: PermissionPayload;
|
||||||
properties: Record<string, string>;
|
properties: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
449
web/src/types/OPERATION_BaseClass.ts
Normal file
449
web/src/types/OPERATION_BaseClass.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(data: JsonRecord, key: string, fallback = "") {
|
||||||
|
const value = data[key];
|
||||||
|
return value === null || value === undefined ? fallback : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(data: JsonRecord, key: string, fallback = 0) {
|
||||||
|
const value = data[key];
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBoolean(data: JsonRecord, key: string, fallback = false) {
|
||||||
|
const value = data[key];
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "number") return value !== 0;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value.toLowerCase() === "true") return true;
|
||||||
|
if (value.toLowerCase() === "false") return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readArray(data: JsonRecord, key: string) {
|
||||||
|
const value = data[key];
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class P_OPERATION {
|
||||||
|
/**
|
||||||
|
* 一组项目帧
|
||||||
|
*/
|
||||||
|
OPERATION = new C_OPERATION();
|
||||||
|
|
||||||
|
constructor(operation?: C_OPERATION) {
|
||||||
|
if (operation) {
|
||||||
|
this.OPERATION = operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns P_OPERATION
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const operation = new P_OPERATION();
|
||||||
|
if (isRecord(jsonData) && isRecord(jsonData.OPERATION)) {
|
||||||
|
operation.OPERATION = C_OPERATION.fromjson(jsonData.OPERATION);
|
||||||
|
}
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_OPERATION {
|
||||||
|
/**
|
||||||
|
* 帧集合
|
||||||
|
*/
|
||||||
|
frames: C_Frames[] = [];
|
||||||
|
|
||||||
|
constructor(frames?: C_Frames[]) {
|
||||||
|
this.frames = frames || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_OPERATION
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const operation = new C_OPERATION();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
operation.frames = readArray(jsonData, "frames").map((frameData) => C_Frames.fromjson(frameData));
|
||||||
|
}
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Frames {
|
||||||
|
/**
|
||||||
|
* 帧时间
|
||||||
|
*/
|
||||||
|
time = "0.01";
|
||||||
|
/**
|
||||||
|
* 帧内容集合
|
||||||
|
*/
|
||||||
|
objStates: C_ObjStates[] = [];
|
||||||
|
crafts: C_Craft[] = [];
|
||||||
|
signalwrites: C_Signalwrite[] = [];
|
||||||
|
visibles: C_Visible[] = [];
|
||||||
|
rotations: C_Rotation[] = [];
|
||||||
|
waits: C_Wait[] = [];
|
||||||
|
attachs: C_Attach[] = [];
|
||||||
|
|
||||||
|
constructor(frame?: Partial<C_Frames>) {
|
||||||
|
if (frame) {
|
||||||
|
this.time = frame.time ?? this.time;
|
||||||
|
this.objStates = frame.objStates ?? this.objStates;
|
||||||
|
this.crafts = frame.crafts ?? this.crafts;
|
||||||
|
this.signalwrites = frame.signalwrites ?? this.signalwrites;
|
||||||
|
this.visibles = frame.visibles ?? this.visibles;
|
||||||
|
this.rotations = frame.rotations ?? this.rotations;
|
||||||
|
this.waits = frame.waits ?? this.waits;
|
||||||
|
this.attachs = frame.attachs ?? this.attachs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Frames实例
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const frame = new C_Frames();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
frame.time = readString(jsonData, "time", frame.time);
|
||||||
|
frame.objStates = readArray(jsonData, "objStates").map((objStateData) => C_ObjStates.fromjson(objStateData));
|
||||||
|
frame.crafts = readArray(jsonData, "crafts").map((craftData) => C_Craft.fromjson(craftData));
|
||||||
|
frame.signalwrites = readArray(jsonData, "signalwrites").map((signalData) => C_Signalwrite.fromjson(signalData));
|
||||||
|
frame.visibles = readArray(jsonData, "visibles").map((visibleData) => C_Visible.fromjson(visibleData));
|
||||||
|
frame.rotations = readArray(jsonData, "rotations").map((rotationData) => C_Rotation.fromjson(rotationData));
|
||||||
|
frame.waits = readArray(jsonData, "waits").map((waitData) => C_Wait.fromjson(waitData));
|
||||||
|
frame.attachs = readArray(jsonData, "attachs").map((attachData) => C_Attach.fromjson(attachData));
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_ObjStates {
|
||||||
|
/**
|
||||||
|
* 模型代码
|
||||||
|
*/
|
||||||
|
i = "";
|
||||||
|
/**
|
||||||
|
* tx
|
||||||
|
*/
|
||||||
|
tx = "";
|
||||||
|
/**
|
||||||
|
* ty
|
||||||
|
*/
|
||||||
|
ty = "";
|
||||||
|
/**
|
||||||
|
* tz
|
||||||
|
*/
|
||||||
|
tz = "";
|
||||||
|
/**
|
||||||
|
* qx
|
||||||
|
*/
|
||||||
|
qx = "";
|
||||||
|
/**
|
||||||
|
* qy
|
||||||
|
*/
|
||||||
|
qy = "";
|
||||||
|
/**
|
||||||
|
* qz
|
||||||
|
*/
|
||||||
|
qz = "";
|
||||||
|
/**
|
||||||
|
* qw
|
||||||
|
*/
|
||||||
|
qw = "";
|
||||||
|
|
||||||
|
constructor(objState?: Partial<C_ObjStates>) {
|
||||||
|
if (objState) {
|
||||||
|
this.i = objState.i ?? this.i;
|
||||||
|
this.tx = objState.tx ?? this.tx;
|
||||||
|
this.ty = objState.ty ?? this.ty;
|
||||||
|
this.tz = objState.tz ?? this.tz;
|
||||||
|
this.qx = objState.qx ?? this.qx;
|
||||||
|
this.qy = objState.qy ?? this.qy;
|
||||||
|
this.qz = objState.qz ?? this.qz;
|
||||||
|
this.qw = objState.qw ?? this.qw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_ObjStates实例
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const objState = new C_ObjStates();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
objState.i = readString(jsonData, "i", objState.i);
|
||||||
|
objState.tx = readString(jsonData, "tx", objState.tx);
|
||||||
|
objState.ty = readString(jsonData, "ty", objState.ty);
|
||||||
|
objState.tz = readString(jsonData, "tz", objState.tz);
|
||||||
|
objState.qx = readString(jsonData, "qx", objState.qx);
|
||||||
|
objState.qy = readString(jsonData, "qy", objState.qy);
|
||||||
|
objState.qz = readString(jsonData, "qz", objState.qz);
|
||||||
|
objState.qw = readString(jsonData, "qw", objState.qw);
|
||||||
|
}
|
||||||
|
return objState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Craft {
|
||||||
|
CraftCode = "";
|
||||||
|
CraftValue = "";
|
||||||
|
|
||||||
|
constructor(craft?: Partial<C_Craft>) {
|
||||||
|
if (craft) {
|
||||||
|
this.CraftCode = craft.CraftCode ?? this.CraftCode;
|
||||||
|
this.CraftValue = craft.CraftValue ?? this.CraftValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Craft实例
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const craft = new C_Craft();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
craft.CraftCode = readString(jsonData, "CraftCode", craft.CraftCode);
|
||||||
|
craft.CraftValue = readString(jsonData, "CraftValue", craft.CraftValue);
|
||||||
|
}
|
||||||
|
return craft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Signalwrite {
|
||||||
|
/**
|
||||||
|
* TagID
|
||||||
|
*/
|
||||||
|
TagID = "";
|
||||||
|
/**
|
||||||
|
* TagValue
|
||||||
|
*/
|
||||||
|
TagValue = "";
|
||||||
|
|
||||||
|
constructor(signal?: Partial<C_Signalwrite>) {
|
||||||
|
if (signal) {
|
||||||
|
this.TagID = signal.TagID ?? this.TagID;
|
||||||
|
this.TagValue = signal.TagValue ?? this.TagValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Signalwrite实例
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const signal = new C_Signalwrite();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
signal.TagID = readString(jsonData, "TagID", signal.TagID);
|
||||||
|
signal.TagValue = readString(jsonData, "TagValue", signal.TagValue);
|
||||||
|
}
|
||||||
|
return signal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Visible {
|
||||||
|
ModelCode = "";
|
||||||
|
Visible = "";
|
||||||
|
|
||||||
|
constructor(visible?: Partial<C_Visible>) {
|
||||||
|
if (visible) {
|
||||||
|
this.ModelCode = visible.ModelCode ?? this.ModelCode;
|
||||||
|
this.Visible = visible.Visible ?? this.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Visible
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const visible = new C_Visible();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
visible.ModelCode = readString(jsonData, "ModelCode", visible.ModelCode);
|
||||||
|
visible.Visible = readString(jsonData, "Visible", visible.Visible);
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Rotation {
|
||||||
|
ModelCode = "";
|
||||||
|
Axis = "";
|
||||||
|
Rate = 0;
|
||||||
|
|
||||||
|
constructor(rotation?: Partial<C_Rotation>) {
|
||||||
|
if (rotation) {
|
||||||
|
this.ModelCode = rotation.ModelCode ?? this.ModelCode;
|
||||||
|
this.Axis = rotation.Axis ?? this.Axis;
|
||||||
|
this.Rate = rotation.Rate ?? this.Rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Rotation
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const rotation = new C_Rotation();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
rotation.ModelCode = readString(jsonData, "ModelCode", rotation.ModelCode);
|
||||||
|
rotation.Axis = readString(jsonData, "Axis", rotation.Axis);
|
||||||
|
rotation.Rate = readNumber(jsonData, "Rate", rotation.Rate);
|
||||||
|
}
|
||||||
|
return rotation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Wait {
|
||||||
|
WaitStr = "";
|
||||||
|
|
||||||
|
constructor(wait?: Partial<C_Wait>) {
|
||||||
|
if (wait) {
|
||||||
|
this.WaitStr = wait.WaitStr ?? this.WaitStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Wait实例
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const wait = new C_Wait();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
wait.WaitStr = readString(jsonData, "WaitStr", wait.WaitStr);
|
||||||
|
}
|
||||||
|
return wait;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class C_Attach {
|
||||||
|
AttachToModelCode = "";
|
||||||
|
AttachToModelName = "";
|
||||||
|
List_AttachModel: ModelGroup[] = [];
|
||||||
|
IsTwoWay = false;
|
||||||
|
IsAttach = false;
|
||||||
|
|
||||||
|
constructor(attach?: Partial<C_Attach>) {
|
||||||
|
if (attach) {
|
||||||
|
this.AttachToModelCode = attach.AttachToModelCode ?? this.AttachToModelCode;
|
||||||
|
this.AttachToModelName = attach.AttachToModelName ?? this.AttachToModelName;
|
||||||
|
this.List_AttachModel = attach.List_AttachModel ?? this.List_AttachModel;
|
||||||
|
this.IsTwoWay = attach.IsTwoWay ?? this.IsTwoWay;
|
||||||
|
this.IsAttach = attach.IsAttach ?? this.IsAttach;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Attach实例
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const attach = new C_Attach();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
attach.AttachToModelCode = readString(jsonData, "AttachToModelCode", attach.AttachToModelCode);
|
||||||
|
attach.AttachToModelName = readString(jsonData, "AttachToModelName", attach.AttachToModelName);
|
||||||
|
attach.IsTwoWay = readBoolean(jsonData, "IsTwoWay", attach.IsTwoWay);
|
||||||
|
attach.IsAttach = readBoolean(jsonData, "IsAttach", attach.IsAttach);
|
||||||
|
attach.List_AttachModel = readArray(jsonData, "List_AttachModel")
|
||||||
|
.filter(isRecord)
|
||||||
|
.map((modelData) => new ModelGroup(
|
||||||
|
readString(modelData, "ModelCode"),
|
||||||
|
readString(modelData, "ModelName")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return attach;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// / <summary>
|
||||||
|
// / 焦点选择描述
|
||||||
|
// / 2025.08.17.2050.LLX
|
||||||
|
// / </summary>
|
||||||
|
class C_Focus {
|
||||||
|
id = "";
|
||||||
|
name = "";
|
||||||
|
time = "3000";
|
||||||
|
|
||||||
|
constructor(focus?: Partial<C_Focus>) {
|
||||||
|
if (focus) {
|
||||||
|
this.id = focus.id ?? this.id;
|
||||||
|
this.name = focus.name ?? this.name;
|
||||||
|
this.time = focus.time ?? this.time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON数据反序列化
|
||||||
|
* @param jsonData JSON格式的数据
|
||||||
|
* @returns C_Focus
|
||||||
|
*/
|
||||||
|
static fromjson(jsonData: unknown) {
|
||||||
|
const focus = new C_Focus();
|
||||||
|
if (isRecord(jsonData)) {
|
||||||
|
focus.id = readString(jsonData, "id", focus.id);
|
||||||
|
focus.name = readString(jsonData, "name", focus.name);
|
||||||
|
focus.time = readString(jsonData, "time", focus.time);
|
||||||
|
}
|
||||||
|
return focus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModelGroup {
|
||||||
|
/**
|
||||||
|
* 模型代码
|
||||||
|
*/
|
||||||
|
ModelCode = "";
|
||||||
|
/**
|
||||||
|
* 模型名称
|
||||||
|
*/
|
||||||
|
ModelName = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param modelCode 模型代码
|
||||||
|
* @param modelName 模型名称
|
||||||
|
*/
|
||||||
|
constructor(modelCode = "", modelName = "") {
|
||||||
|
this.ModelCode = modelCode;
|
||||||
|
this.ModelName = modelName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
C_Attach,
|
||||||
|
C_Craft,
|
||||||
|
C_Frames,
|
||||||
|
C_OPERATION,
|
||||||
|
C_ObjStates,
|
||||||
|
C_Rotation,
|
||||||
|
C_Signalwrite,
|
||||||
|
C_Visible,
|
||||||
|
C_Wait,
|
||||||
|
ModelGroup,
|
||||||
|
P_OPERATION,
|
||||||
|
C_Focus
|
||||||
|
};
|
||||||
150
web/src/types/OperationTree.ts
Normal file
150
web/src/types/OperationTree.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// 数据库存储的数据
|
||||||
|
interface OperationTreeDB {
|
||||||
|
OperationTree: string; // JSON字符串,格式化之后是 OperationTree[] 列表
|
||||||
|
ModelProcess?: string; // JSON字符串,格式化之后是 ModelProcess[] 列表
|
||||||
|
ProcesslLabelList?: string; // JSON字符串,格式化之后是 ProcesslLabelList[] 列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工艺数据机构
|
||||||
|
interface OperationTree {
|
||||||
|
/** 主键ID */
|
||||||
|
ID: string;
|
||||||
|
/** 是否自动插入 */
|
||||||
|
IsAutoInsert?: boolean | null;
|
||||||
|
/** 工艺代码(主键) */
|
||||||
|
CraftCode: string;
|
||||||
|
/** 工艺类型代码 */
|
||||||
|
CraftTypeCode: string;
|
||||||
|
/** 品种 */
|
||||||
|
Varieties: string;
|
||||||
|
/** 工艺名称 */
|
||||||
|
CraftName: string;
|
||||||
|
/** 工艺设备类型代码 */
|
||||||
|
CraftDeviceTypeCode: number;
|
||||||
|
/** 项目代码(主键) */
|
||||||
|
ProjectCode: string;
|
||||||
|
/** 操作时间 */
|
||||||
|
OperationTime: Date | string;
|
||||||
|
/** 序列号 */
|
||||||
|
SequenceNo?: number | null;
|
||||||
|
/** 开始时间 */
|
||||||
|
StartTime?: number | null;
|
||||||
|
/** 持续时间 */
|
||||||
|
KeepTime?: number | null;
|
||||||
|
/** 模型代码 */
|
||||||
|
ModelCode: string | null;
|
||||||
|
/** 工艺播放数据 */
|
||||||
|
CraftPlayData?: string | null;
|
||||||
|
/** 关节工艺播放数据 */
|
||||||
|
CraftPlayData_Joint?: string | null;
|
||||||
|
/** 原始工艺播放数据 */
|
||||||
|
CraftPlayData_Raw: string;
|
||||||
|
/** 速率 */
|
||||||
|
Rate: number | 0;
|
||||||
|
/** 说明 */
|
||||||
|
Explain?: string | null;
|
||||||
|
/** 反向 */
|
||||||
|
Reverse?: number | null;
|
||||||
|
/** 当前播放 */
|
||||||
|
CurrPlay?: boolean | null;
|
||||||
|
/** 脚本文本 */
|
||||||
|
ScriptText?: string | null;
|
||||||
|
/** 是否存在脚本 */
|
||||||
|
ExistScript?: boolean | null;
|
||||||
|
/** 甘特图父节点 */
|
||||||
|
GanttParent?: string | null;
|
||||||
|
/** 父模型代码 */
|
||||||
|
ModelCodeParent?: string | null;
|
||||||
|
/** 父模型名称 */
|
||||||
|
ModelNameParent?: string | null;
|
||||||
|
/** 模型名称 */
|
||||||
|
ModelName?: string | null;
|
||||||
|
/** 标签ID */
|
||||||
|
TagID?: string | null;
|
||||||
|
/** 标签值 */
|
||||||
|
TagValue?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果需要创建新对象的接口(所有字段可选)
|
||||||
|
interface OperationTreeCreate {
|
||||||
|
ID?: string;
|
||||||
|
IsAutoInsert?: boolean;
|
||||||
|
CraftCode: string;
|
||||||
|
CraftTypeCode?: string;
|
||||||
|
Varieties?: string;
|
||||||
|
CraftName?: string;
|
||||||
|
CraftDeviceTypeCode?: number;
|
||||||
|
ProjectCode: string;
|
||||||
|
OperationTime?: Date | string;
|
||||||
|
SequenceNo?: number;
|
||||||
|
StartTime?: number;
|
||||||
|
KeepTime?: number;
|
||||||
|
ModelCode?: string;
|
||||||
|
CraftPlayData?: string;
|
||||||
|
CraftPlayData_Joint?: string;
|
||||||
|
CraftPlayData_Raw?: string;
|
||||||
|
Rate?: number;
|
||||||
|
Explain?: string;
|
||||||
|
Reverse?: number;
|
||||||
|
CurrPlay?: boolean;
|
||||||
|
ScriptText?: string;
|
||||||
|
ExistScript?: boolean;
|
||||||
|
GanttParent?: string;
|
||||||
|
ModelCodeParent?: string;
|
||||||
|
ModelNameParent?: string;
|
||||||
|
ModelName?: string;
|
||||||
|
TagID?: string;
|
||||||
|
TagValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationTreeExtra extends OperationTree {
|
||||||
|
pId: string;
|
||||||
|
id: string;
|
||||||
|
parent: string;
|
||||||
|
state: any;
|
||||||
|
text: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
checked: boolean;
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsTreeData_node_OperationTree {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
original: OperationTreeExtra;
|
||||||
|
icon: string;
|
||||||
|
children: string[]
|
||||||
|
children_d: string[]
|
||||||
|
parent: string
|
||||||
|
parents: string[]
|
||||||
|
state: {
|
||||||
|
loaded: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
opened: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface JsTreeData_OperationTree_drop {
|
||||||
|
node: JsTreeData_node_OperationTree;
|
||||||
|
old_parent: string;
|
||||||
|
parent: string;
|
||||||
|
old_position: number;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
interface OperationTree_SequenceNo {
|
||||||
|
CraftTypeCode: string;
|
||||||
|
CraftCode: string;
|
||||||
|
SequenceNo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
OperationTreeDB,
|
||||||
|
OperationTree,
|
||||||
|
OperationTreeCreate,
|
||||||
|
OperationTreeExtra,
|
||||||
|
OperationTree_SequenceNo,
|
||||||
|
JsTreeData_node_OperationTree,
|
||||||
|
JsTreeData_OperationTree_drop
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { w2alert, w2confirm, w2popup } from "w2ui";
|
import { w2popup, w2utils } from "../vendor/w2ui";
|
||||||
|
|
||||||
type PopupActionEvent = {
|
type PopupActionEvent = {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -10,7 +10,11 @@ type PopupActionEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function notify(message: string, title = "提示") {
|
export function notify(message: string, title = "提示") {
|
||||||
w2alert(message, title);
|
w2utils.alert({
|
||||||
|
box: "body",
|
||||||
|
title,
|
||||||
|
text: message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyError(error: unknown) {
|
export function notifyError(error: unknown) {
|
||||||
@@ -19,14 +23,27 @@ export function notifyError(error: unknown) {
|
|||||||
|
|
||||||
export function confirmDialog(message: string, title = "确认") {
|
export function confirmDialog(message: string, title = "确认") {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
w2confirm({
|
let settled = false;
|
||||||
msg: message,
|
const settle = (value: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
w2utils.confirm({
|
||||||
|
box: "body",
|
||||||
title,
|
title,
|
||||||
yes: "确定",
|
text: message,
|
||||||
no: "取消"
|
btn_yes: {
|
||||||
}, undefined, (action: string) => {
|
text: "确定"
|
||||||
resolve(action === "yes" || action === "Yes");
|
},
|
||||||
});
|
btn_no: {
|
||||||
|
text: "取消"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.yes(() => settle(true))
|
||||||
|
.no(() => settle(false))
|
||||||
|
.close(() => settle(false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +51,7 @@ export function formDialog<T>(options: {
|
|||||||
title: string;
|
title: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
blockPage?: boolean;
|
||||||
body: string;
|
body: string;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onSubmit: () => Promise<T> | T;
|
onSubmit: () => Promise<T> | T;
|
||||||
@@ -53,6 +71,7 @@ export function formDialog<T>(options: {
|
|||||||
height: options.height ?? 300,
|
height: options.height ?? 300,
|
||||||
modal: true,
|
modal: true,
|
||||||
body: options.body,
|
body: options.body,
|
||||||
|
blockPage: options.blockPage ?? false,
|
||||||
actions: {
|
actions: {
|
||||||
取消() {
|
取消() {
|
||||||
closeWith(null);
|
closeWith(null);
|
||||||
@@ -92,6 +111,7 @@ export async function promptDialog(options: {
|
|||||||
return formDialog<string>({
|
return formDialog<string>({
|
||||||
title: options.title,
|
title: options.title,
|
||||||
height: 210,
|
height: 210,
|
||||||
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<div class="popup-form">
|
<div class="popup-form">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
30
web/src/utils/fileDownload.ts
Normal file
30
web/src/utils/fileDownload.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { resolveStorageUrl } from "../config/runtime";
|
||||||
|
|
||||||
|
export async function downloadFileFromUrl(fileUrl: string, fileName: string) {
|
||||||
|
const response = await fetch(resolveStorageUrl(fileUrl), {
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`文件下载失败:${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = ensureGlbFileName(fileName);
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
anchor.style.display = "none";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGlbFileName(fileName: string) {
|
||||||
|
const name = fileName.trim() || "model.glb";
|
||||||
|
return name.toLowerCase().endsWith(".glb") ? name : `${name}.glb`;
|
||||||
|
}
|
||||||
86
web/src/vendor/w2ui.ts
vendored
Normal file
86
web/src/vendor/w2ui.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 与主程序 src/publicJs/w2ui 保持同步的模型库本地副本,这里只做类型适配。
|
||||||
|
// @ts-expect-error w2ui 本地 JS 暂无配套 d.ts,运行时由 Vite 正常打包。
|
||||||
|
import * as w2uiLocal from "./w2ui/w2ui.es6.js";
|
||||||
|
|
||||||
|
type W2EventHandler = (event: unknown) => void;
|
||||||
|
type W2EventHost = {
|
||||||
|
on(eventName: string, handler: W2EventHandler): W2EventHost;
|
||||||
|
};
|
||||||
|
|
||||||
|
type W2Popup = {
|
||||||
|
open(options: {
|
||||||
|
title?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
modal?: boolean;
|
||||||
|
blockPage?: boolean;
|
||||||
|
body?: string;
|
||||||
|
actions?: Record<string, () => void | Promise<void>>;
|
||||||
|
}): {
|
||||||
|
self: W2EventHost;
|
||||||
|
};
|
||||||
|
close(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type W2Utils = {
|
||||||
|
alert(options: {
|
||||||
|
box?: string | HTMLElement;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
}): unknown;
|
||||||
|
confirm(options: {
|
||||||
|
box?: string | HTMLElement;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
btn_yes?: {
|
||||||
|
text?: string;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
attrs?: string;
|
||||||
|
};
|
||||||
|
btn_no?: {
|
||||||
|
text?: string;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
attrs?: string;
|
||||||
|
};
|
||||||
|
}): W2ConfirmPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
type W2ConfirmPromise = {
|
||||||
|
yes(callback: (event: unknown) => void): W2ConfirmPromise;
|
||||||
|
no(callback: (event: unknown) => void): W2ConfirmPromise;
|
||||||
|
close(callback: (event: unknown) => void): W2ConfirmPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
type W2LayoutOptions = {
|
||||||
|
name: string;
|
||||||
|
padding?: number;
|
||||||
|
panels: Array<{
|
||||||
|
type: "left" | "right" | "top" | "bottom" | "main" | "preview";
|
||||||
|
size?: number | string;
|
||||||
|
minSize?: number;
|
||||||
|
maxSize?: number | false;
|
||||||
|
resizable?: boolean;
|
||||||
|
overflow?: string;
|
||||||
|
html?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type W2Layout = {
|
||||||
|
render(target: HTMLElement): void;
|
||||||
|
destroy(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const local = w2uiLocal as Record<string, unknown>;
|
||||||
|
|
||||||
|
export const w2popup = local.w2popup as W2Popup;
|
||||||
|
export const w2layout = local.w2layout as new (options: W2LayoutOptions) => W2Layout;
|
||||||
|
export const w2ui = local.w2ui as Record<string, { destroy?: () => void } | undefined>;
|
||||||
|
export const w2utils = local.w2utils as W2Utils;
|
||||||
|
export const w2alert = local.w2alert as (message: string, title?: string) => unknown;
|
||||||
|
export const w2confirm = local.w2confirm as (
|
||||||
|
message: string | { msg: string; title?: string; yes?: string; no?: string },
|
||||||
|
title?: string,
|
||||||
|
callback?: (action: string) => void
|
||||||
|
) => unknown;
|
||||||
4250
web/src/vendor/w2ui/w2ui.css
vendored
Normal file
4250
web/src/vendor/w2ui/w2ui.css
vendored
Normal file
File diff suppressed because one or more lines are too long
24634
web/src/vendor/w2ui/w2ui.es6.js
vendored
Normal file
24634
web/src/vendor/w2ui/w2ui.es6.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,40 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
export default defineConfig({
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
server: {
|
const rootDir = path.resolve(__dirname, "../..");
|
||||||
port: 5174,
|
|
||||||
strictPort: true,
|
function getDevHttpsConfig() {
|
||||||
proxy: {
|
return {
|
||||||
"/api": "http://localhost:3001",
|
key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")),
|
||||||
"/storage": "http://localhost:3001"
|
cert: fs.readFileSync(path.join(rootDir, "certs/localhost+2.pem"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ command }) => ({
|
||||||
|
base: command === "build" ? "/modelLibrary/" : "/",
|
||||||
|
server: command === "serve"
|
||||||
|
? {
|
||||||
|
port: 5174,
|
||||||
|
strictPort: true,
|
||||||
|
https: getDevHttpsConfig(),
|
||||||
|
headers: {
|
||||||
|
// 关键:添加 COEP 和 COOP
|
||||||
|
"Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp"
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
"Cross-Origin-Resource-Policy": "cross-origin", // 改为 cross-origin
|
||||||
|
// 开发环境允许跨域
|
||||||
|
"Access-Control-Allow-Origin": "https://localhost:3000", // 父页面的地址
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Access-Control-Allow-Credentials": "true"
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:3001",
|
||||||
|
"/storage": "http://127.0.0.1:3001"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
: undefined
|
||||||
});
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user