Compare commits

...

43 Commits

Author SHA1 Message Date
zhangshun
91ae484b36 修改模型树相关配置 2026-07-11 13:41:11 +08:00
zhangshun
0673de9ebe 修正模型库子路径构建配置 2026-07-10 13:34:03 +08:00
zhangshun
15d2bc51ca 上传模型时自动生成预览图 2026-07-08 18:38:48 +08:00
zhangshun
0665d7a008 上传模型时显示全屏进度 2026-07-08 18:27:51 +08:00
zhangshun
025835c5b3 优化批量上传模型提示 2026-07-08 18:18:11 +08:00
zhangshun
a3e7d93872 支持批量导入模型 2026-07-08 18:03:24 +08:00
zhangshun
e2e7aa5f06 增加前端运行时后端地址配置 2026-06-02 13:32:08 +08:00
zhangshun
8fb45bd42a 生产构建跳过本地开发证书 2026-06-02 11:46:55 +08:00
zhangshun
e215f93ef2 修改部分按钮显示 2026-06-02 10:38:40 +08:00
zhangshun
bbed65e41e 调整模型操作按钮布局 2026-06-02 10:33:37 +08:00
zhangshun
d4e78a666e 免登录隐藏人员权限按钮 2026-06-02 10:07:44 +08:00
zhangshun
74f3e60013 修改测试用例 2026-06-02 10:04:02 +08:00
zhangshun
91f689c3d4 隐藏模型价格重量字段 2026-06-01 18:02:10 +08:00
zhangshun
c1d5240d2c 固定模型缩略图比例 2026-06-01 16:56:35 +08:00
zhangshun
33f1163aa7 调整预览基点显示单位 2026-06-01 16:52:54 +08:00
zhangshun
7ab0c2bba4 支持预览界面接收场景基点 2026-06-01 15:55:39 +08:00
zhangshun
2368d6e6c1 调整模型预览下载按钮位置 2026-06-01 15:24:35 +08:00
zhangshun
b1ef3deb03 增加模型库模型下载入口 2026-06-01 14:34:44 +08:00
zhangshun
5b6d29f0f6 修复场景导入模型名称提交 2026-06-01 14:18:43 +08:00
zhangshun
a059805458 调整模型库上传模型的字段 2026-06-01 14:14:16 +08:00
zhangshun
6206c9556b 优化模型删除后的列表刷新 2026-06-01 13:26:31 +08:00
zhangshun
00f7dc1686 增加模型库无工艺数据播放提示 2026-06-01 13:07:50 +08:00
zhangshun
57d3137640 修复模型库跨端口消息发送 2026-06-01 10:40:04 +08:00
zhangshun
cc58a6e98d 固定模型库工艺预览帧间隔 2026-06-01 10:18:08 +08:00
zhangshun
b6c3bd9922 修复模型库工艺预览播放速度 2026-06-01 10:12:38 +08:00
zhangshun
c14a6aa75c 增强场景模型导入请求反馈 2026-06-01 10:06:55 +08:00
zhangshun
692d61105e 刷新模型库目录模型数量 2026-06-01 09:57:35 +08:00
zhangshun
fcec4f3db4 修复模型库空请求体删除失败 2026-06-01 09:50:52 +08:00
zhangshun
3dfed3a2df 统一模型库确认框调用方式 2026-06-01 09:40:36 +08:00
zhangshun
738476c7f9 修改预览界面样式和按钮位置 2026-06-01 09:20:04 +08:00
zhangshun
6f6be751a9 接入模型预览工艺播放 2026-06-01 08:58:14 +08:00
zhangshun
0ace6c0413 调整模型预览左侧宽度 2026-05-31 11:41:40 +08:00
zhangshun
7ceed76d50 限制模型预览工艺列表高度 2026-05-30 09:38:22 +08:00
zhangshun
bda06279df 保留工艺模型上传扩展数据 2026-05-29 17:56:18 +08:00
zhangshun
0b99801b26 修复工艺模型上传文件名校验 2026-05-29 17:33:29 +08:00
zhangshun
aeaeb9aec5 完善模型库界面和开发配置 2026-05-29 17:24:33 +08:00
zhangshun
35934f8088 修复模型库提示弹窗调用 2026-05-29 17:16:26 +08:00
zhangshun
b27bf2c83b 支持从主程序导入场景模型 2026-05-29 16:59:52 +08:00
zhangshun
c923e9e54d 支持模型库向主程序导入模型 2026-05-29 16:35:03 +08:00
zhangshun
5d10902e10 添加模型库开发资源CORP响应头 2026-05-29 12:00:14 +08:00
zhangshun
697802a2af 支持模型库HTTPS开发服务 2026-05-29 11:42:36 +08:00
zhangshun
6e4ce87844 修复模型库开发代理地址 2026-05-29 11:30:33 +08:00
zhangshun
71496de8e3 统一模型库前端基础依赖 2026-05-29 11:21:15 +08:00
25 changed files with 30665 additions and 343 deletions

View File

@@ -5,9 +5,9 @@ DB_PATH=
# login: show login page
# fixed: auto login with FIXED_LOGIN_USERNAME and open model library directly
APP_AUTH_MODE=login
FIXED_LOGIN_USERNAME=
FIXED_LOGIN_ROLE=user
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

140
package-lock.json generated
View File

@@ -1114,20 +1114,6 @@
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"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": {
"version": "0.5.24",
"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"
}
},
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -2320,12 +2312,6 @@
"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": {
"version": "3.0.0",
"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",
"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": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3766,11 +3747,6 @@
"@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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3882,17 +3858,42 @@
"dependencies": {
"jquery": "^3.7.1",
"jstree": "^3.3.17",
"three": "^0.184.0",
"w2ui": "^2.0.0"
"three": "^0.183.2"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"@types/jstree": "^3.3.46",
"@types/three": "^0.184.1",
"@types/three": "^0.183.0",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"typescript": "^5.7.2",
"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": {
@@ -4475,20 +4476,6 @@
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"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": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@@ -4502,6 +4489,12 @@
"dev": true,
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -4824,7 +4817,7 @@
"@types/node": "^24.0.0",
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.15.4",
"dotenv": "*",
"dotenv": "^17.4.2",
"fastify": "^5.2.1",
"nanoid": "^5.0.9",
"tsx": "^4.19.2",
@@ -4837,14 +4830,41 @@
"requires": {
"@types/jquery": "^3.5.32",
"@types/jstree": "^3.3.46",
"@types/three": "^0.184.1",
"@types/three": "^0.183.0",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"jquery": "^3.7.1",
"jstree": "^3.3.17",
"three": "^0.184.0",
"three": "^0.183.2",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"w2ui": "^2.0.0"
"vite": "^6.0.7"
},
"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": {
@@ -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": {
"version": "3.0.0",
"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": {
"version": "0.2.16",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -7,6 +7,7 @@
</head>
<body>
<div id="app"></div>
<script src="%BASE_URL%model-library-config.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -11,13 +11,12 @@
"dependencies": {
"jquery": "^3.7.1",
"jstree": "^3.3.17",
"three": "^0.184.0",
"w2ui": "^2.0.0"
"three": "^0.183.2"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"@types/jstree": "^3.3.46",
"@types/three": "^0.184.1",
"@types/three": "^0.183.0",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"

View 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
View 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);
}

View File

@@ -1,5 +1,5 @@
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 { api } from "./services/api";
import { getToken, setCurrentUser, setSession, clearSession } from "./services/authState";

View File

@@ -1,11 +1,12 @@
.app-shell {
height: 100vh;
display: grid;
grid-template-rows: 42px 1fr;
/* grid-template-rows: 42px 1fr; */
}
.topbar {
display: flex;
/* display: flex; */
display: none;
align-items: center;
justify-content: space-between;
gap: 14px;
@@ -50,6 +51,7 @@
display: grid;
grid-template-rows: auto auto 1fr auto;
overflow: hidden;
background: #fff;
}
.content-toolbar {
@@ -80,17 +82,17 @@
.filter-toolbar {
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;
gap: 8px;
padding: 8px 12px;
background: #f8fafb;
/* background: #f8fafb; */
border-bottom: 1px solid #d8e0e8;
}
.filter-toolbar label {
display: grid;
gap: 3px;
/* display: grid;
gap: 3px; */
}
.filter-toolbar span {
@@ -113,44 +115,46 @@
overflow: auto;
padding: 10px 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
align-content: start;
gap: 10px;
}
.model-card {
display: grid;
gap: 8px;
gap: 5px;
background: #ffffff;
border: 1px solid #d7e0e8;
border-radius: 6px;
padding: 8px;
padding: 5px;
}
.thumb {
position: relative;
height: 150px;
aspect-ratio: 4 / 3;
min-height: 0;
display: grid;
place-items: center;
background:
/* background:
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%),
#f8fafb;
#f8fafb; */
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
background-size: 12px 12px;
border: 1px solid #d3dde5;
border-radius: 4px;
overflow: hidden;
padding: 4px;
/* padding: 4px; */
}
.thumb-actions {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(2, minmax(0, 86px));
align-content: center;
align-items: center;
justify-content: center;
gap: 7px;
@@ -169,7 +173,7 @@
}
.thumb-actions button {
width: min(86px, 80%);
width: 100%;
min-height: 24px;
padding: 3px 8px;
border-color: rgba(255, 255, 255, 0.3);
@@ -177,6 +181,7 @@
}
.thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
@@ -253,6 +258,7 @@ dd {
.pagination-total,
.pagination-range {
display: none;
color: #647586;
font-size: 12px;
}
@@ -331,7 +337,7 @@ dd {
}
.keyword-filter {
grid-column: 1 / -1;
/* grid-column: 1 / -1; */
}
.pagination {

View File

@@ -64,6 +64,105 @@
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);
@@ -102,25 +201,31 @@
.preview-shell {
height: 100%;
max-height: 100%;
padding: 0;
background: #f4f7f9;
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 250px 1fr;
min-height: 0;
overflow: hidden;
}
.preview-process-panel {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
border-right: 1px solid #d8e0e8;
background: #ffffff;
overflow: hidden;
}
.preview-process-section {
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-rows: auto minmax(0, 1fr) auto auto;
}
.preview-process-header {
@@ -128,7 +233,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
padding: 0 10px;
border-bottom: 1px solid #d8e0e8;
}
@@ -139,10 +244,12 @@
.preview-process-tree {
min-height: 0;
max-height: 100%;
overflow: auto;
overscroll-behavior: contain;
list-style: none;
margin: 0;
padding: 8px;
padding: 5px 0;
}
.preview-model-meta {
@@ -178,9 +285,14 @@
.preview-process-tree button {
width: 100%;
min-height: 30px;
display: block;
text-align: left;
border-color: transparent;
background: transparent;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-process-tree li.is-active button {
@@ -198,6 +310,10 @@
}
.preview-process-status {
display: none;
min-height: 44px;
max-height: 78px;
overflow: auto;
padding: 6px 8px 8px;
border-top: 1px solid #eef2f5;
color: #647586;
@@ -206,6 +322,10 @@
}
.preview-scene-section {
min-height: 0;
max-height: 236px;
overflow: auto;
overscroll-behavior: contain;
border-top: 1px solid #d8e0e8;
}
@@ -215,6 +335,16 @@
padding: 8px;
}
.preview-scene-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-scene-actions > button {
flex: 1;
}
.preview-scene-body > button {
width: 100%;
}
@@ -255,13 +385,14 @@
.preview-canvas-panel {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.preview-viewport {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
min-height: 0;
overflow: hidden;
}

View File

@@ -1,4 +1,4 @@
import { w2layout, w2ui } from "w2ui";
import { w2layout, w2ui } from "../../vendor/w2ui";
import { getCurrentUser, clearSession } from "../../services/authState";
import { loadFolders } from "./modules/folders";
import { bindModelActions } from "./modules/models";
@@ -10,6 +10,7 @@ export async function renderApp() {
const currentUser = getCurrentUser();
const isAdmin = currentUser?.role === "admin";
const isFixedLogin = document.body.dataset.authMode === "fixed";
const canManageUsers = isAdmin && !isFixedLogin;
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<div class="app-shell">
<header class="topbar">
@@ -31,14 +32,11 @@ export async function renderApp() {
<template id="modelPanelTemplate">
<main class="content-pane">
<div class="content-toolbar">
<div>
<h2>模型列表</h2>
</div>
<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>` : ""}
<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>
<button id="addModelBtn" class="primary-btn" type="button" hidden>增加模型</button>
${canManageUsers ? `<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>` : ""}
${canManageUsers ? `<button id="addModelBtn1" class="primary-btn" type="button" hidden>增加模型</button>` : ""}
</div>
</div>
<div class="filter-toolbar">
@@ -58,7 +56,8 @@ export async function renderApp() {
<span>关键字</span>
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
</label>
<button id="resetFilterBtn" type="button">重置</button>
<button id="resetFilterBtn" type="button">筛选模型</button>
<button id="addModelBtn" class="primary-btn" type="button">导入模型</button>
</div>
<div id="modelGrid" class="model-grid"></div>
<footer id="modelPagination" class="pagination"></footer>
@@ -92,8 +91,8 @@ function renderWorkspaceLayout() {
panels: [
{
type: "left",
size: 300,
minSize: 220,
size: 180,
minSize: 150,
resizable: true,
overflow: "hidden",
html: treeTemplate.innerHTML

View File

@@ -24,6 +24,7 @@ export async function openDictionaryManager(onChanged?: () => Promise<void> | vo
title: "品牌 / 类型维护",
width: 680,
height: 520,
blockPage: false,
body: `
<div class="dictionary-manager">
${dictionaryPanelHtml("brands", "品牌")}

View File

@@ -63,7 +63,8 @@ async function runFolderAction(action: () => Promise<void>) {
}
}
export async function loadFolders() {
export async function loadFolders(options: { reloadModels?: boolean } = {}) {
const reloadModels = options.reloadModels ?? true;
const result = await api<FolderTreeResponse>("/api/folders");
appState.folders = result.folders;
const root = appState.folders.find((folder) => folder.parent_id === null);
@@ -125,8 +126,10 @@ export async function loadFolders() {
if (appState.selectedFolderId) {
$("#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();
}
}

View File

@@ -3,11 +3,16 @@ import { renderPagination } from "../../../components/pagination";
import { getCurrentUser } from "../../../services/authState";
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
import { downloadFileFromUrl } from "../../../utils/fileDownload";
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
import { resolveStorageUrl } from "../../../config/runtime";
import { appState } from "../appState";
import { generateModelThumbnailFromFile } from "./thumbnail";
type UploadFormState = {
file: File | null;
files: File[];
thumbnailFailures: string[];
};
type UploadModelPayload = {
@@ -21,12 +26,40 @@ type UploadModelPayload = {
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() {
const isAdmin = getCurrentUser()?.role === "admin";
bindSceneModelBridge();
ensureImportSceneModelButton();
if (isAdmin) {
document.querySelector("#manageUsersBtn")?.addEventListener("click", async () => {
try {
@@ -100,7 +133,7 @@ export function bindModelActions() {
});
}
export async function loadModels() {
export async function loadModels(options: { preserveCards?: boolean } = {}) {
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
if (!grid || !appState.selectedFolderId) return;
await loadDictionaries();
@@ -130,37 +163,89 @@ export async function loadModels() {
await loadModels();
}
});
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => {
const model = result.items.find((item) => item.id === Number(button.dataset.id));
if (model) {
button.addEventListener("click", async () => {
visibleModels = new Map(result.items.map((item) => [item.id, item]));
renderModelGrid(grid, result.items, options.preserveCards);
bindModelGridActions(grid);
}
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");
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) => {
const model = result.items.find((item) => item.id === Number(button.dataset.id));
if (!model) return;
button.addEventListener("click", async () => {
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("导入场景数据已输出");
});
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
});
}
function renderModelGrid(grid: HTMLDivElement, items: ModelItem[], preserveCards = false) {
if (!preserveCards) {
grid.innerHTML = items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
return;
}
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) {
@@ -210,8 +295,8 @@ function renderModelCard(item: ModelItem) {
const canWrite = item.permissions.write;
const prop = item.properties ?? {};
const thumb = item.thumbnail_url
? `<img src="${item.thumbnail_url}" alt="" />`
: `<div class="thumb-placeholder">GLB</div>`;
? `<img src="${resolveStorageUrl(item.thumbnail_url)}" alt="" />`
: `<div class="thumb-placeholder">暂无预览图</div>`;
return `
<article class="model-card" data-model-id="${item.id}">
<div class="model-info">
@@ -221,6 +306,7 @@ function renderModelCard(item: ModelItem) {
${thumb}
<div class="thumb-actions">
<button data-action="preview" 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>
${canWrite ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
@@ -230,8 +316,6 @@ function renderModelCard(item: ModelItem) {
<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(prop.model ?? "")}</dd></div>
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</dd></div>
</dl>
</article>
`;
@@ -242,8 +326,129 @@ function syncSelectedFolderActions() {
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) {
@@ -262,7 +467,7 @@ export async function uploadModelToBackend(payload: UploadModelPayload) {
const form = new FormData();
form.set("folderId", String(folderId));
form.set("name", modelNameFromFile(displayName));
form.set("name", displayName);
form.set("brandName", payload.brandName ?? "");
form.set("typeName", payload.typeName ?? "");
form.set("operationTree", normalizeOperationTreeInput(payload.operationTree));
@@ -271,10 +476,11 @@ export async function uploadModelToBackend(payload: UploadModelPayload) {
form.set(`prop.${key}`, value);
}
await api("/api/models/upload", {
const result = await api<UploadModelResponse>("/api/models/upload", {
method: "POST",
body: form
});
return result.id;
}
async function openUploadModelDialog() {
@@ -283,20 +489,21 @@ async function openUploadModelDialog() {
return;
}
const state: UploadFormState = { file: null };
const state: UploadFormState = { file: null, files: [], thumbnailFailures: [] };
await loadDictionaries();
const result = await formDialog<boolean>({
title: "增加模型",
width: 560,
height: 520,
height: 430,
blockPage: false,
body: `
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
<label>
<span>模型文件</span>
<div id="modelDropZone" class="drop-zone">
<input id="modelUploadFile" type="file" accept=".glb" />
<input id="modelUploadFile" type="file" accept=".glb" multiple />
<strong>选择模型</strong>
<em>或拖拽 .glb 模型到这里</em>
<em>或拖拽一个或多个 .glb 模型到这里</em>
<small id="selectedFileName">未选择文件</small>
</div>
</label>
@@ -308,42 +515,95 @@ async function openUploadModelDialog() {
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
<label><span>型号</span><input name="model" /></label>
<label><span>价钱</span><input name="price" /></label>
<label><span>重量</span><input name="weight" /></label>
</div>
<div id="modelUploadStatus" class="upload-status" aria-live="polite" hidden></div>
${dictionaryDatalistHtml()}
</form>
`,
onOpen: () => bindUploadDialogEvents(state),
onSubmit: async () => {
if (!state.file) {
if (state.files.length === 0) {
throw new Error("请选择 .glb 模型文件");
}
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
if (!name) {
if (state.files.length === 1 && !name) {
throw new Error("模型名称不能为空");
}
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
await uploadModelToBackend({
file: state.file,
name,
fileName: state.file.name,
brandName: String(form.get("brandName") ?? ""),
typeName: String(form.get("typeName") ?? ""),
operationTree: defaultOperationTree,
properties: {
model: String(form.get("model") ?? ""),
price: String(form.get("price") ?? ""),
weight: String(form.get("weight") ?? "")
const brandName = String(form.get("brandName") ?? "");
const typeName = String(form.get("typeName") ?? "");
const properties = {
model: String(form.get("model") ?? "")
};
let completedCount = 0;
const totalCount = state.files.length;
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);
}
}
return true;
}
});
if (result) {
if (state.thumbnailFailures.length > 0) {
notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`);
} else {
notify("模型上传和预览图生成完成");
}
invalidateDictionaries();
await loadModels();
await reloadFoldersAndModels();
}
}
@@ -357,43 +617,22 @@ export async function openProcessModelUploadDialog(input?: {
return;
}
const state: UploadFormState = { file: input?.file ?? null };
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: "上传工艺模型",
width: 640,
height: 650,
title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}`,
width: 400,
height: 300,
blockPage: false,
body: `
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form">
<div class="upload-target-path">
<span>上传目录</span>
<strong>${escapeHtml(appState.selectedFolderName || "当前目录")}</strong>
</div>
<label>
<span>模型文件</span>
<div id="processModelDropZone" class="drop-zone">
<input id="processModelUploadFile" type="file" accept=".glb" />
<strong>选择模型</strong>
<em>或拖拽 .glb 模型到这里</em>
<small id="processSelectedFileName">${escapeHtml(input?.file?.name ?? "未选择文件")}</small>
</div>
</label>
<label>
<span>模型名称</span>
<input id="processModelUploadName" name="name" placeholder="例如 AA.glb" value="${escapeHtml(initialName)}" />
</label>
<div class="popup-form-grid">
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
<label><span>型</span><input name="model" /></label>
<label><span>价钱</span><input name="price" /></label>
<label><span>重量</span><input name="weight" /></label>
<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>
<label>
<span>工艺数据 JSON</span>
<textarea id="processOperationTreeJson" name="operationTree" placeholder='可以粘贴 {"OperationTree":"[...]"} 或 OperationTree[] 数组 JSON'>${escapeHtml(input?.operationTreeJson ?? "")}</textarea>
</label>
${dictionaryDatalistHtml()}
</form>
`,
@@ -404,26 +643,29 @@ export async function openProcessModelUploadDialog(input?: {
}
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
const name = String(form.get("name") ?? "").trim();
await uploadModelToBackend({
const modelId = await uploadModelToBackend({
file: state.file,
name,
fileName: name || 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") ?? ""),
price: String(form.get("price") ?? ""),
weight: String(form.get("weight") ?? "")
model: String(form.get("model") ?? "")
}
});
try {
await generateAndSaveModelThumbnail(modelId, state.file);
} catch {
notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。");
}
return true;
}
});
if (result) {
invalidateDictionaries();
await loadModels();
await reloadFoldersAndModels();
}
}
@@ -433,21 +675,48 @@ function bindUploadDialogEvents(state: UploadFormState) {
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
const selectFile = (file: File) => {
if (!file.name.toLowerCase().endsWith(".glb")) {
const renderSelectedFiles = () => {
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 模型");
return;
}
state.file = file;
selectedFileName.textContent = file.name;
if (!nameInput.value.trim()) {
nameInput.value = modelNameFromFile(file.name);
}
state.files = selectedFiles;
state.file = selectedFiles[0] ?? null;
renderSelectedFiles();
};
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) selectFile(file);
if (fileInput.files) selectFiles(fileInput.files);
});
dropZone.addEventListener("click", (event) => {
@@ -464,58 +733,140 @@ function bindUploadDialogEvents(state: UploadFormState) {
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragover");
const file = event.dataTransfer?.files?.[0];
if (file) selectFile(file);
const files = event.dataTransfer?.files;
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 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;
}
};
// 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;
}
}
// 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);
});
// fileInput.addEventListener("change", () => {
// const file = fileInput.files?.[0];
// if (file) selectFile(file);
// });
dropZone.addEventListener("click", (event) => {
if (event.target !== fileInput) fileInput.click();
});
// 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);
});
// 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) {
@@ -533,27 +884,47 @@ function normalizeOperationTreeInput(value?: string) {
}
if (parsed && typeof parsed === "object" && "OperationTree" in parsed) {
const operationTree = (parsed as { OperationTree: unknown }).OperationTree;
const normalized = { ...(parsed as Record<string, unknown>) };
const operationTree = normalized.OperationTree;
if (typeof operationTree === "string") {
JSON.parse(operationTree);
return JSON.stringify({ OperationTree: operationTree });
normalized.OperationTree = operationTree;
normalizeOptionalJsonListField(normalized, "ModelProcess");
normalizeOptionalJsonListField(normalized, "ProcesslLabelList");
return JSON.stringify(normalized);
}
if (Array.isArray(operationTree)) {
return JSON.stringify({ OperationTree: JSON.stringify(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) {
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
const oldName = card?.querySelector("strong")?.textContent ?? "";
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: "编辑模型",
width: 520,
height: 390,
blockPage: false,
body: `
<form id="modelEditPopupForm" class="popup-form">
<label><span>模型名称</span><input name="name" value="${escapeHtml(oldName)}" /></label>
@@ -561,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="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="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>
${dictionaryDatalistHtml()}
</form>
@@ -577,9 +946,7 @@ async function editModel(id: number) {
name,
brandName: String(form.get("brandName") ?? ""),
typeName: String(form.get("typeName") ?? ""),
model: String(form.get("model") ?? ""),
price: String(form.get("price") ?? ""),
weight: String(form.get("weight") ?? "")
model: String(form.get("model") ?? "")
};
}
});
@@ -591,9 +958,7 @@ async function editModel(id: number) {
brandName: result.brandName,
typeName: result.typeName,
properties: {
model: result.model,
price: result.price,
weight: result.weight
model: result.model
}
})
});
@@ -616,5 +981,5 @@ async function deleteModel(id: number) {
const confirmed = await confirmDialog("确认删除该模型?");
if (!confirmed) return;
await api(`/api/models/${id}`, { method: "DELETE" });
await loadModels();
await reloadFoldersAndModels(true);
}

View File

@@ -1,13 +1,15 @@
import { w2popup } from "w2ui";
import { w2popup } from "../../../vendor/w2ui";
import type { Object3D, PerspectiveCamera, Scene, Texture, WebGLRenderer } from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { ModelItem } from "../../../types";
import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree";
import { P_OPERATION } from "../../../types/OPERATION_BaseClass";
import { downloadFileFromUrl } from "../../../utils/fileDownload";
import { escapeHtml, formatBytes } from "../../../utils/format";
import { notify, notifyError } from "../../../ui/dialogs";
import { api } from "../../../services/api";
import { resolveStorageUrl } from "../../../config/runtime";
type PreviewRuntime = {
renderer: WebGLRenderer;
@@ -36,6 +38,29 @@ type ParseableGltfLoader = GLTFLoader & {
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
};
type PreviewObjectSnapshot = {
position: ReturnType<Object3D["position"]["clone"]>;
quaternion: ReturnType<Object3D["quaternion"]["clone"]>;
visible: boolean;
parent: Object3D | null;
};
type PreviewPlaybackState = {
operation: PreviewOperation | null;
isPlaying: boolean;
rafId: number | null;
startedAtMs: number;
baseTimeMs: number;
currentTimeMs: number;
currentFrameIndex: number;
frameTimesMs: number[];
totalTimeMs: number;
objectSnapshots: Map<Object3D, PreviewObjectSnapshot>;
objectMap: Map<string, Object3D>;
attachedOriginalParents: Map<Object3D, Object3D | null>;
rotatingObjects: Map<Object3D, { axis: "x" | "y" | "z"; rate: number }>;
};
export type PreviewBasePoint = {
x?: number;
y?: number;
@@ -64,10 +89,32 @@ export type PreviewImportScenePayload = {
selectedCraftPlayData: P_OPERATION | null;
};
type ParentImportScenePayload = {
model: Pick<ModelItem, "id" | "name" | "original_filename" | "file_url">;
modelUrl: string;
basePointEnabled: boolean;
basePoint: PreviewResolvedBasePoint;
rawOperationTree: string;
};
type ParentBasePointMessage = {
type?: string;
message?: string;
payload?: {
modelId?: string;
modelName?: string;
basePoint?: PreviewBasePoint;
};
};
export type ModelPreviewOptions = {
onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | void;
};
const BASE_POINT_SUBSCRIBE_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_SUBSCRIBE";
const BASE_POINT_UNSUBSCRIBE_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_UNSUBSCRIBE";
const BASE_POINT_UPDATE_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_UPDATE";
const BASE_POINT_ERROR_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_ERROR";
const zeroBasePoint: PreviewResolvedBasePoint = {
x: 0,
y: 0,
@@ -76,12 +123,22 @@ const zeroBasePoint: PreviewResolvedBasePoint = {
ry: 0,
rz: 0
};
const RAD_TO_DEG = 180 / Math.PI;
const DEG_TO_RAD = Math.PI / 180;
const BASE_POINT_DECIMAL_PLACES = 3;
const THUMBNAIL_WIDTH = 512;
const THUMBNAIL_HEIGHT = 384;
const previewEnvironmentUrl = "/DayCityOutdoor.exr";
const DEFAULT_PREVIEW_FRAME_INTERVAL_MS = 20;
const SECOND_MS = 1000;
let runtime: PreviewRuntime | null = null;
let activePreviewContext: PreviewLoadContext | null = null;
let previewEnvironmentTexturePromise: Promise<Texture> | null = null;
let previewPlayback: PreviewPlaybackState = createEmptyPlaybackState();
let basePointBridgeBound = false;
let basePointSubscribed = false;
export function openModelPreview(
model: ModelItem,
@@ -90,13 +147,14 @@ export function openModelPreview(
) {
const context = beginPreviewContext();
const canWrite = model.permissions.write;
const url = model.file_url;
const url = resolveStorageUrl(model.file_url);
const operations = parseModelOperations(model.operation_tree);
const popup = w2popup.open({
title: `模型预览 - ${escapeHtml(model.name)}${formatBytes(model.file_size)}`,
width: 860,
height: 620,
modal: true,
blockPage: false,
body: `
<div class="preview-shell">
<aside class="preview-process-panel">
@@ -118,25 +176,21 @@ export function openModelPreview(
</div>
<div class="preview-scene-section">
<div class="preview-scene-body">
${canWrite ? `
<button id="previewCaptureThumbBtn" type="button">截缩略图</button>
<div class="preview-scene-actions">
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
<label class="preview-switch">
<input id="previewTransparentThumb" type="checkbox" />
<span>透明背景截图</span>
<input id="previewUseBasePoint" type="checkbox" />
<span>启用基点</span>
</label>
` : ""}
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
<label class="preview-switch">
<input id="previewUseBasePoint" type="checkbox" />
<span>启用基点导入</span>
</label>
</div>
<div class="preview-basepoint-grid">
<label><span>X</span><input name="baseX" type="number" value="0" step="0.001" /></label>
<label><span>Y</span><input name="baseY" type="number" value="0" step="0.001" /></label>
<label><span>Z</span><input name="baseZ" type="number" value="0" step="0.001" /></label>
<label><span>RX</span><input name="baseRx" type="number" value="0" step="0.001" /></label>
<label><span>RY</span><input name="baseRy" type="number" value="0" step="0.001" /></label>
<label><span>RZ</span><input name="baseRz" type="number" value="0" step="0.001" /></label>
<label><span>X(mm)</span><input name="baseX" type="number" value="0.000" step="0.001" /></label>
<label><span>Y(mm)</span><input name="baseY" type="number" value="0.000" step="0.001" /></label>
<label><span>Z(mm)</span><input name="baseZ" type="number" value="0.000" step="0.001" /></label>
<label><span>RX(°)</span><input name="baseRx" type="number" value="0.000" step="0.001" /></label>
<label><span>RY(°)</span><input name="baseRy" type="number" value="0.000" step="0.001" /></label>
<label><span>RZ(°)</span><input name="baseRz" type="number" value="0.000" step="0.001" /></label>
</div>
</div>
</div>
@@ -149,6 +203,16 @@ export function openModelPreview(
</div>
`,
actions: {
() {
bindDownloadModel(model);
},
() {
if (canWrite) {
screenshotModel(model.id, onThumbnailSaved).catch((error) => {
})
}
},
() {
disposePreview();
w2popup.close();
@@ -157,8 +221,10 @@ export function openModelPreview(
});
popup.self
.on("open:after", () => {
.on("open:after", () => {popup
console.log(popup)
bindProcessPlaceholder(operations);
bindBasePointSelection();
bindImportScene(model, operations, options.onImportScene);
if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
requestAnimationFrame(() => {
@@ -167,7 +233,10 @@ export function openModelPreview(
});
});
})
.on("close:after", () => disposePreview());
.on("close:after", () => {
unsubscribeBasePointSelection();
disposePreview();
});
}
export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true) {
@@ -182,7 +251,7 @@ export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true)
for (const [key, name] of fieldMap) {
const input = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`);
if (input && basePoint[key] !== undefined) {
input.value = String(basePoint[key]);
input.value = formatBasePointDisplayValue(key, basePoint[key]);
}
}
@@ -214,6 +283,110 @@ export function createImportScenePayload(options: {
export function emitImportScenePayload(payload: PreviewImportScenePayload) {
window.dispatchEvent(new CustomEvent<PreviewImportScenePayload>("dmt:import-scene", { detail: payload }));
if (window.parent && window.parent !== window) {
try {
window.parent.postMessage({
type: "DMT_MODEL_LIBRARY_IMPORT_SCENE",
payload: buildParentImportScenePayload(payload)
}, resolveParentPostMessageOrigin());
} catch (error) {
notifyError(error);
}
}
}
function buildParentImportScenePayload(payload: PreviewImportScenePayload): ParentImportScenePayload {
return {
model: {
id: payload.model.id,
name: payload.model.name,
original_filename: payload.model.original_filename,
file_url: resolveStorageUrl(payload.model.file_url)
},
modelUrl: payload.modelUrl,
basePointEnabled: payload.basePointEnabled,
basePoint: payload.basePoint,
rawOperationTree: payload.rawOperationTree
};
}
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 isAllowedParentOrigin(origin: string) {
const expectedOrigin = resolveParentOrigin();
return expectedOrigin === "*" || origin === expectedOrigin || isLocalDmtParentOrigin(origin);
}
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 bindBasePointSelection() {
bindBasePointBridge();
const checkbox = document.querySelector<HTMLInputElement>("#previewUseBasePoint");
checkbox?.addEventListener("change", () => {
if (checkbox.checked) {
subscribeBasePointSelection();
} else {
unsubscribeBasePointSelection();
}
});
if (checkbox?.checked) subscribeBasePointSelection();
}
function bindBasePointBridge() {
if (basePointBridgeBound) return;
basePointBridgeBound = true;
window.addEventListener("message", (event) => {
const data = event.data as ParentBasePointMessage;
if (data?.type !== BASE_POINT_UPDATE_MESSAGE && data?.type !== BASE_POINT_ERROR_MESSAGE) return;
if (!isAllowedParentOrigin(event.origin)) return;
if (data.type === BASE_POINT_ERROR_MESSAGE) {
notify(data.message || "请先在主场景中选择模型或坐标");
return;
}
if (data.payload?.basePoint) {
setPreviewBasePoint(data.payload.basePoint, true);
}
});
}
function subscribeBasePointSelection() {
if (!window.parent || window.parent === window) return;
basePointSubscribed = true;
window.parent.postMessage({
type: BASE_POINT_SUBSCRIBE_MESSAGE
}, resolveParentPostMessageOrigin());
}
function unsubscribeBasePointSelection() {
if (!basePointSubscribed) return;
basePointSubscribed = false;
if (!window.parent || window.parent === window) return;
window.parent.postMessage({
type: BASE_POINT_UNSUBSCRIBE_MESSAGE
}, resolveParentPostMessageOrigin());
}
function parseModelOperations(value: string): PreviewOperation[] {
@@ -360,6 +533,7 @@ function isAbortError(error: unknown) {
}
function cleanupPreviewContextResources(context: PreviewLoadContext) {
stopPreviewPlayback();
if (context.animationId !== undefined) {
cancelAnimationFrame(context.animationId);
context.animationId = undefined;
@@ -408,40 +582,62 @@ function disposeMaterial(material: unknown) {
}
function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", () => {
screenshotModel(modelId, onThumbnailSaved).catch((error) => {
})
});
}
async function screenshotModel(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
try {
if (!runtime) {
throw new Error("模型还未加载完成");
}
const { renderer, scene, camera, controls } = runtime.context;
if (!renderer || !scene || !camera || !controls) {
throw new Error("模型还未加载完成");
}
const transparent = true; // document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
controls.update();
const oldBackground = scene.background;
const oldClearAlpha = renderer.getClearAlpha();
const oldPixelRatio = renderer.getPixelRatio();
const oldWidth = renderer.domElement.width / oldPixelRatio;
const oldHeight = renderer.domElement.height / oldPixelRatio;
const oldAspect = camera.aspect;
let thumbnail = "";
try {
if (!runtime) {
throw new Error("模型还未加载完成");
}
const { renderer, scene, camera, controls } = runtime.context;
if (!renderer || !scene || !camera || !controls) {
throw new Error("模型还未加载完成");
}
const transparent = document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
controls.update();
const oldBackground = scene.background;
const oldClearAlpha = renderer.getClearAlpha();
if (transparent) {
scene.background = null;
renderer.setClearColor(0x000000, 0);
}
renderer.setPixelRatio(1);
renderer.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, false);
camera.aspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
const thumbnail = renderer.domElement.toDataURL("image/png");
thumbnail = renderer.domElement.toDataURL("image/png");
} finally {
camera.aspect = oldAspect;
camera.updateProjectionMatrix();
renderer.setPixelRatio(oldPixelRatio);
renderer.setSize(oldWidth, oldHeight, false);
if (transparent) {
scene.background = oldBackground;
renderer.setClearAlpha(oldClearAlpha);
renderer.render(scene, camera);
}
await api(`/api/models/${modelId}/thumbnail`, {
method: "PUT",
body: JSON.stringify({ thumbnail })
});
await onThumbnailSaved?.();
notify("缩略图已保存");
} catch (error) {
notifyError(error);
renderer.render(scene, camera);
}
});
await api(`/api/models/${modelId}/thumbnail`, {
method: "PUT",
body: JSON.stringify({ thumbnail })
});
await onThumbnailSaved?.();
notify("缩略图已保存");
} catch (error) {
notifyError(error);
}
}
function getSelectedOperation(operations: PreviewOperation[]) {
@@ -452,12 +648,12 @@ function getSelectedOperation(operations: PreviewOperation[]) {
function readPreviewBasePoint(): PreviewResolvedBasePoint {
return resolveBasePoint({
x: readNumberInput("baseX"),
y: readNumberInput("baseY"),
z: readNumberInput("baseZ"),
rx: readNumberInput("baseRx"),
ry: readNumberInput("baseRy"),
rz: readNumberInput("baseRz")
x: roundBasePointValue(readNumberInput("baseX")),
y: roundBasePointValue(readNumberInput("baseY")),
z: roundBasePointValue(readNumberInput("baseZ")),
rx: degreesToRadians(readNumberInput("baseRx")),
ry: degreesToRadians(readNumberInput("baseRy")),
rz: degreesToRadians(readNumberInput("baseRz"))
});
}
@@ -467,6 +663,30 @@ function readNumberInput(name: string) {
return Number.isFinite(numberValue) ? numberValue : 0;
}
function formatBasePointDisplayValue(key: keyof PreviewResolvedBasePoint, value: number) {
const displayValue = key === "rx" || key === "ry" || key === "rz"
? radiansToDegrees(value)
: value;
return formatBasePointNumber(displayValue);
}
function radiansToDegrees(value: number) {
return roundBasePointValue(value * RAD_TO_DEG);
}
function degreesToRadians(value: number) {
return Number.isFinite(value) ? value * DEG_TO_RAD : 0;
}
function roundBasePointValue(value: number) {
if (!Number.isFinite(value)) return 0;
return Number(value.toFixed(BASE_POINT_DECIMAL_PLACES));
}
function formatBasePointNumber(value: number) {
return roundBasePointValue(value).toFixed(BASE_POINT_DECIMAL_PLACES);
}
function bindImportScene(
model: ModelItem,
operations: PreviewOperation[],
@@ -492,6 +712,14 @@ function bindImportScene(
});
}
async function bindDownloadModel(model: ModelItem) {
try {
await downloadFileFromUrl(model.file_url, model.original_filename || model.name);
} catch (error) {
notifyError(error);
}
}
function resolveBasePoint(basePoint?: PreviewBasePoint): PreviewResolvedBasePoint {
return {
x: basePoint?.x ?? zeroBasePoint.x,
@@ -512,7 +740,7 @@ function buildImportScenePayload(options: {
}): PreviewImportScenePayload {
return {
model: options.model,
modelUrl: options.model.file_url,
modelUrl: resolveStorageUrl(options.model.file_url),
basePointEnabled: options.basePointEnabled,
basePoint: options.basePoint,
rawOperationTree: options.model.operation_tree,
@@ -535,11 +763,16 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
button.closest("li")?.classList.add("is-active");
const index = Number(button.dataset.processIndex ?? 0);
stopPreviewPlayback();
updateStatus(operations[index] ?? null);
});
});
document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
if (operations.length === 0) {
notify("当前模型暂无工艺数据");
return;
}
const operation = getSelectedOperation(operations);
updateStatus(operation);
if (!operation) {
@@ -550,19 +783,389 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
notifyError(`工艺播放数据解析失败:${operation.parseError}`);
return;
}
if (!operation.parsedCraftPlayData) {
if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
notify("当前工艺暂无播放数据");
return;
}
notify(`${operation.CraftName || "当前工艺"} 已读取 ${operation.frameCount} 帧,播放逻辑待接入`);
startPreviewPlayback(operation);
});
document.querySelector<HTMLButtonElement>("#previewPauseBtn")?.addEventListener("click", () => {
updateStatus(getSelectedOperation(operations));
notify("暂停逻辑待接入");
pausePreviewPlayback();
});
}
function startPreviewPlayback(operation: PreviewOperation) {
if (!runtime?.context.modelObject) {
notify("模型还未加载完成");
return;
}
if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
notify("当前工艺暂无播放数据");
return;
}
if (previewPlayback.isPlaying && previewPlayback.operation === operation) {
return;
}
if (previewPlayback.operation === operation && previewPlayback.currentTimeMs > 0 && previewPlayback.currentTimeMs < previewPlayback.totalTimeMs) {
resumePreviewPlayback();
return;
}
resetPreviewPlaybackScene();
const frames = operation.parsedCraftPlayData.OPERATION.frames;
const frameTimesMs = buildPreviewFrameTimes(frames);
previewPlayback = {
operation,
isPlaying: true,
rafId: null,
startedAtMs: performance.now(),
baseTimeMs: 0,
currentTimeMs: 0,
currentFrameIndex: -1,
frameTimesMs,
totalTimeMs: frameTimesMs[frameTimesMs.length - 1] ?? 0,
objectSnapshots: capturePreviewObjectSnapshots(runtime.context.modelObject),
objectMap: buildPreviewObjectMap(runtime.context.modelObject, operation),
attachedOriginalParents: new Map(),
rotatingObjects: new Map()
};
setPlaybackButtons(true);
updatePreviewPlaybackStatus(operation, 0);
previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback);
}
function pausePreviewPlayback() {
if (!previewPlayback.isPlaying) return;
refreshPreviewPlaybackClock();
cancelPreviewPlaybackRaf();
previewPlayback.isPlaying = false;
setPlaybackButtons(false);
updatePreviewPlaybackStatus(previewPlayback.operation, previewPlayback.currentTimeMs);
}
function resumePreviewPlayback() {
if (!previewPlayback.operation || previewPlayback.isPlaying) return;
if (previewPlayback.currentTimeMs >= previewPlayback.totalTimeMs) {
resetPreviewPlaybackScene();
previewPlayback.currentTimeMs = 0;
previewPlayback.currentFrameIndex = -1;
previewPlayback.baseTimeMs = 0;
}
previewPlayback.isPlaying = true;
previewPlayback.startedAtMs = performance.now();
previewPlayback.baseTimeMs = previewPlayback.currentTimeMs;
setPlaybackButtons(true);
previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback);
}
function stopPreviewPlayback() {
cancelPreviewPlaybackRaf();
resetPreviewPlaybackScene();
previewPlayback = createEmptyPlaybackState();
setPlaybackButtons(false);
}
function stepPreviewPlayback(timestamp: number) {
if (!previewPlayback.isPlaying || !previewPlayback.operation?.parsedCraftPlayData || !runtime?.context.modelObject) return;
const nextTimeMs = Math.min(
previewPlayback.baseTimeMs + Math.max(0, timestamp - previewPlayback.startedAtMs),
previewPlayback.totalTimeMs
);
applyPreviewContinuousRotations(nextTimeMs);
previewPlayback.currentTimeMs = nextTimeMs;
const nextFrameIndex = resolvePreviewFrameIndex(previewPlayback.currentTimeMs, previewPlayback.frameTimesMs);
applyPreviewFramesUntil(nextFrameIndex);
updatePreviewPlaybackStatus(previewPlayback.operation, previewPlayback.currentTimeMs);
if (previewPlayback.currentTimeMs >= previewPlayback.totalTimeMs) {
cancelPreviewPlaybackRaf();
previewPlayback.isPlaying = false;
setPlaybackButtons(false);
return;
}
previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback);
}
function refreshPreviewPlaybackClock() {
if (!previewPlayback.isPlaying) return;
previewPlayback.currentTimeMs = Math.min(
previewPlayback.baseTimeMs + Math.max(0, performance.now() - previewPlayback.startedAtMs),
previewPlayback.totalTimeMs
);
previewPlayback.baseTimeMs = previewPlayback.currentTimeMs;
previewPlayback.startedAtMs = performance.now();
}
function applyPreviewFramesUntil(nextFrameIndex: number) {
const frames = previewPlayback.operation?.parsedCraftPlayData?.OPERATION.frames ?? [];
if (nextFrameIndex <= previewPlayback.currentFrameIndex) return;
for (let index = previewPlayback.currentFrameIndex + 1; index <= nextFrameIndex && index < frames.length; index += 1) {
applyPreviewFrame(frames[index]);
}
previewPlayback.currentFrameIndex = Math.min(nextFrameIndex, frames.length - 1);
}
function applyPreviewFrame(frame: P_OPERATION["OPERATION"]["frames"][number]) {
for (const attachData of frame.attachs ?? []) {
if (attachData.IsAttach) {
applyPreviewAttach(attachData.AttachToModelCode, attachData.List_AttachModel ?? []);
}
}
for (const visibleData of frame.visibles ?? []) {
const object = findPreviewObject(visibleData.ModelCode);
if (object) object.visible = parseInteger(visibleData.Visible) === 1;
}
for (const craftData of frame.crafts ?? []) {
if (parseInteger(craftData.CraftValue) === 1) {
const operation = findPreviewOperation(craftData.CraftCode);
if (operation?.parsedCraftPlayData) {
applyPreviewFrame(operation.parsedCraftPlayData.OPERATION.frames[0]);
}
}
}
for (const rotationData of frame.rotations ?? []) {
applyPreviewRotation(rotationData.ModelCode, rotationData.Axis, Number(rotationData.Rate));
}
for (const moveData of frame.objStates ?? []) {
applyPreviewObjState(moveData);
}
for (const attachData of frame.attachs ?? []) {
if (!attachData.IsAttach) {
detachPreviewAttach(attachData.List_AttachModel ?? []);
}
}
}
function applyPreviewObjState(moveData: P_OPERATION["OPERATION"]["frames"][number]["objStates"][number]) {
const object = findPreviewObject(moveData.i);
if (!object || !isValidFrameTransform(moveData)) return;
const position = parsePreviewNumbers(moveData.tx, moveData.ty, moveData.tz);
const quaternion = parsePreviewNumbers(moveData.qx, moveData.qy, moveData.qz, moveData.qw);
if (!position || !quaternion) return;
object.position.set(position[0], position[1], position[2]);
object.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
object.updateMatrixWorld();
}
function applyPreviewRotation(modelCode: string, axisValue: string, rate: number) {
const object = findPreviewObject(modelCode);
const axis = normalizeAxis(axisValue);
if (!object || !axis || !Number.isFinite(rate)) return;
if (Math.abs(rate) <= 0.000001) {
previewPlayback.rotatingObjects.delete(object);
return;
}
previewPlayback.rotatingObjects.set(object, { axis, rate });
}
function applyPreviewContinuousRotations(nextTimeMs: number) {
if (previewPlayback.rotatingObjects.size === 0) return;
const deltaSeconds = Math.max(0, nextTimeMs - previewPlayback.currentTimeMs) / SECOND_MS;
for (const [object, rotation] of previewPlayback.rotatingObjects) {
object.rotation[rotation.axis] += rotation.rate * deltaSeconds;
object.updateMatrixWorld();
}
}
function applyPreviewAttach(rootCode: string, attachModels: Array<{ ModelCode: string }>) {
const root = findPreviewObject(rootCode);
if (!root) return;
for (const attachModel of attachModels) {
const object = findPreviewObject(attachModel.ModelCode);
if (!object || object === root) continue;
if (!previewPlayback.attachedOriginalParents.has(object)) {
previewPlayback.attachedOriginalParents.set(object, object.parent);
}
root.attach(object);
}
}
function detachPreviewAttach(attachModels: Array<{ ModelCode: string }>) {
for (const attachModel of attachModels) {
const object = findPreviewObject(attachModel.ModelCode);
if (!object) continue;
const originalParent = previewPlayback.attachedOriginalParents.get(object);
if (originalParent) {
originalParent.attach(object);
} else {
runtime?.scene.attach(object);
}
previewPlayback.attachedOriginalParents.delete(object);
}
}
function resetPreviewPlaybackScene() {
cancelPreviewPlaybackRaf();
previewPlayback.rotatingObjects.clear();
for (const [object, originalParent] of previewPlayback.attachedOriginalParents) {
if (originalParent) {
originalParent.attach(object);
}
}
previewPlayback.attachedOriginalParents.clear();
for (const [object, snapshot] of previewPlayback.objectSnapshots) {
if (snapshot.parent && object.parent !== snapshot.parent) {
snapshot.parent.attach(object);
}
object.position.copy(snapshot.position);
object.quaternion.copy(snapshot.quaternion);
object.visible = snapshot.visible;
object.updateMatrixWorld();
}
}
function buildPreviewFrameTimes(frames: P_OPERATION["OPERATION"]["frames"]) {
return frames.map((_, index) => index * DEFAULT_PREVIEW_FRAME_INTERVAL_MS);
}
function resolvePreviewFrameIndex(currentTimeMs: number, frameTimesMs: number[]) {
let index = -1;
for (let i = 0; i < frameTimesMs.length; i += 1) {
if (currentTimeMs >= frameTimesMs[i]) index = i;
else break;
}
return index;
}
function buildPreviewObjectMap(root: Object3D, operation: PreviewOperation) {
const objectMap = new Map<string, Object3D>();
root.traverse((object) => {
const values = [
object.userData?.ModelId,
object.userData?.ModelCode,
object.userData?.RootId,
object.userData?.id,
object.name
];
for (const value of values) {
const key = normalizePreviewCode(value);
if (key && !objectMap.has(key)) objectMap.set(key, object);
}
});
const targetCodes = collectPreviewOperationModelCodes(operation);
if (targetCodes.length > 0 && !targetCodes.some(code => objectMap.has(code))) {
for (const code of targetCodes) objectMap.set(code, root);
}
return objectMap;
}
function capturePreviewObjectSnapshots(root: Object3D) {
const snapshots = new Map<Object3D, PreviewObjectSnapshot>();
root.traverse((object) => {
snapshots.set(object, {
position: object.position.clone(),
quaternion: object.quaternion.clone(),
visible: object.visible,
parent: object.parent
});
});
return snapshots;
}
function collectPreviewOperationModelCodes(operation: PreviewOperation) {
const codes = new Set<string>();
if (operation.ModelCode) codes.add(normalizePreviewCode(operation.ModelCode));
if (operation.ModelCodeParent) codes.add(normalizePreviewCode(operation.ModelCodeParent));
for (const frame of operation.parsedCraftPlayData?.OPERATION.frames ?? []) {
for (const state of frame.objStates ?? []) codes.add(normalizePreviewCode(state.i));
for (const item of frame.visibles ?? []) codes.add(normalizePreviewCode(item.ModelCode));
for (const item of frame.rotations ?? []) codes.add(normalizePreviewCode(item.ModelCode));
for (const item of frame.attachs ?? []) {
codes.add(normalizePreviewCode(item.AttachToModelCode));
for (const child of item.List_AttachModel ?? []) codes.add(normalizePreviewCode(child.ModelCode));
}
}
return [...codes].filter(Boolean);
}
function findPreviewObject(modelCode: string) {
const code = normalizePreviewCode(modelCode);
return code ? previewPlayback.objectMap.get(code) ?? null : null;
}
function findPreviewOperation(craftCode: string) {
const code = normalizePreviewCode(craftCode);
if (!code || !previewPlayback.operation) return null;
return previewPlayback.operation.CraftCode === code ? previewPlayback.operation : null;
}
function parsePreviewNumbers(...valuesRaw: string[]) {
const values = valuesRaw.map(Number);
if (values.some(value => !Number.isFinite(value))) return null;
return values;
}
function isValidFrameTransform(moveData: P_OPERATION["OPERATION"]["frames"][number]["objStates"][number]) {
return [moveData.tx, moveData.ty, moveData.tz, moveData.qx, moveData.qy, moveData.qz, moveData.qw]
.every(value => value !== "NaN" && value !== "");
}
function normalizeAxis(axis: string): "x" | "y" | "z" | null {
const value = axis.trim().toLowerCase();
return value === "x" || value === "y" || value === "z" ? value : null;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizePreviewCode(value: unknown) {
return String(value ?? "").trim();
}
function updatePreviewPlaybackStatus(operation: PreviewOperation | null, currentTimeMs: number) {
const status = document.querySelector<HTMLDivElement>("#previewProcessStatus");
if (!status || !operation) return;
const total = Math.max(previewPlayback.totalTimeMs, 0);
const frameText = previewPlayback.currentFrameIndex >= 0
? `${previewPlayback.currentFrameIndex + 1}/${operation.frameCount}`
: `0/${operation.frameCount}`;
status.innerHTML = `播放 ${frameText}${formatPlaybackSeconds(currentTimeMs)}/${formatPlaybackSeconds(total)}s`;
}
function setPlaybackButtons(isPlaying: boolean) {
const playButton = document.querySelector<HTMLButtonElement>("#previewPlayBtn");
const pauseButton = document.querySelector<HTMLButtonElement>("#previewPauseBtn");
if (playButton) playButton.textContent = isPlaying ? "播放中" : "播放";
if (pauseButton) pauseButton.disabled = !isPlaying;
}
function cancelPreviewPlaybackRaf() {
if (previewPlayback.rafId !== null) {
window.cancelAnimationFrame(previewPlayback.rafId);
previewPlayback.rafId = null;
}
}
function createEmptyPlaybackState(): PreviewPlaybackState {
return {
operation: null,
isPlaying: false,
rafId: null,
startedAtMs: 0,
baseTimeMs: 0,
currentTimeMs: 0,
currentFrameIndex: -1,
frameTimesMs: [],
totalTimeMs: 0,
objectSnapshots: new Map(),
objectMap: new Map(),
attachedOriginalParents: new Map(),
rotatingObjects: new Map()
};
}
function formatPlaybackSeconds(ms: number) {
return (Math.max(0, ms) / SECOND_MS).toFixed(2);
}
async function initPreview(url: string, context: PreviewLoadContext) {
ensurePreviewContext(context);
const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");

View 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();
}

View File

@@ -16,6 +16,7 @@ export async function openUserManager() {
title: "人员权限管理",
width: 980,
height: 560,
blockPage: false,
body: `
<div class="user-manager">
<header class="user-manager-head">

View File

@@ -1,4 +1,5 @@
import { getToken } from "./authState";
import { resolveApiUrl } from "../config/runtime";
function authHeaders(): Record<string, string> {
const token = getToken();
@@ -6,10 +7,11 @@ function authHeaders(): Record<string, string> {
}
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,
headers: {
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
...authHeaders(),
...((options.headers as Record<string, string> | undefined) ?? {})
} as HeadersInit
@@ -20,4 +22,3 @@ export async function api<T>(url: string, options: RequestInit = {}): Promise<T>
}
return data as T;
}

View File

@@ -1,6 +1,5 @@
:root {
font-family: "Microsoft YaHei", Arial, sans-serif;
font-size: 13px;
color: #1c2733;
background: #eef2f5;
}
@@ -8,7 +7,9 @@
* {
box-sizing: border-box;
}
html {
font-size: 62.5%;
}
body {
margin: 0;
min-width: 320px;

View File

@@ -1,6 +1,8 @@
// 数据库存储的数据
interface OperationTreeDB {
OperationTree: string; // JSON字符串格式化之后是 OperationTree[] 列表
ModelProcess?: string; // JSON字符串格式化之后是 ModelProcess[] 列表
ProcesslLabelList?: string; // JSON字符串格式化之后是 ProcesslLabelList[] 列表
}
// 工艺数据机构

View File

@@ -1,4 +1,4 @@
import { w2alert, w2confirm, w2popup } from "w2ui";
import { w2popup, w2utils } from "../vendor/w2ui";
type PopupActionEvent = {
detail: {
@@ -10,7 +10,11 @@ type PopupActionEvent = {
};
export function notify(message: string, title = "提示") {
w2alert(message, title);
w2utils.alert({
box: "body",
title,
text: message
});
}
export function notifyError(error: unknown) {
@@ -19,14 +23,27 @@ export function notifyError(error: unknown) {
export function confirmDialog(message: string, title = "确认") {
return new Promise<boolean>((resolve) => {
w2confirm({
msg: message,
let settled = false;
const settle = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
w2utils.confirm({
box: "body",
title,
yes: "确定",
no: "取消"
}, undefined, (action: string) => {
resolve(action === "yes" || action === "Yes");
});
text: message,
btn_yes: {
text: "确定"
},
btn_no: {
text: "取消"
}
})
.yes(() => settle(true))
.no(() => settle(false))
.close(() => settle(false));
});
}
@@ -34,6 +51,7 @@ export function formDialog<T>(options: {
title: string;
width?: number;
height?: number;
blockPage?: boolean;
body: string;
onOpen?: () => void;
onSubmit: () => Promise<T> | T;
@@ -53,6 +71,7 @@ export function formDialog<T>(options: {
height: options.height ?? 300,
modal: true,
body: options.body,
blockPage: options.blockPage ?? false,
actions: {
() {
closeWith(null);
@@ -92,6 +111,7 @@ export async function promptDialog(options: {
return formDialog<string>({
title: options.title,
height: 210,
blockPage: false,
body: `
<div class="popup-form">
<label>

View 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
View 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

File diff suppressed because one or more lines are too long

24634
web/src/vendor/w2ui/w2ui.es6.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,40 @@
import { defineConfig } from "vite";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export default defineConfig({
server: {
port: 5174,
strictPort: true,
proxy: {
"/api": "http://localhost:3001",
"/storage": "http://localhost:3001"
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "../..");
function getDevHttpsConfig() {
return {
key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")),
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
}));