Compare commits
32 Commits
7ceed76d50
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ae484b36 | ||
|
|
0673de9ebe | ||
|
|
15d2bc51ca | ||
|
|
0665d7a008 | ||
|
|
025835c5b3 | ||
|
|
a3e7d93872 | ||
|
|
e2e7aa5f06 | ||
|
|
8fb45bd42a | ||
|
|
e215f93ef2 | ||
|
|
bbed65e41e | ||
|
|
d4e78a666e | ||
|
|
74f3e60013 | ||
|
|
91f689c3d4 | ||
|
|
c1d5240d2c | ||
|
|
33f1163aa7 | ||
|
|
7ab0c2bba4 | ||
|
|
2368d6e6c1 | ||
|
|
b1ef3deb03 | ||
|
|
5b6d29f0f6 | ||
|
|
a059805458 | ||
|
|
6206c9556b | ||
|
|
00f7dc1686 | ||
|
|
57d3137640 | ||
|
|
cc58a6e98d | ||
|
|
b6c3bd9922 | ||
|
|
c14a6aa75c | ||
|
|
692d61105e | ||
|
|
fcec4f3db4 | ||
|
|
3dfed3a2df | ||
|
|
738476c7f9 | ||
|
|
6f6be751a9 | ||
|
|
0ace6c0413 |
@@ -7,7 +7,7 @@ DB_PATH=
|
|||||||
# fixed: auto login with FIXED_LOGIN_USERNAME and open model library directly
|
# fixed: auto login with FIXED_LOGIN_USERNAME and open model library directly
|
||||||
APP_AUTH_MODE=fixed
|
APP_AUTH_MODE=fixed
|
||||||
FIXED_LOGIN_USERNAME=admin
|
FIXED_LOGIN_USERNAME=admin
|
||||||
FIXED_LOGIN_ROLE=user
|
FIXED_LOGIN_ROLE=admin
|
||||||
ALLOW_USER_EDIT_SYSTEM_LIBRARY=false
|
ALLOW_USER_EDIT_SYSTEM_LIBRARY=false
|
||||||
|
|
||||||
# local: write to server/storage and serve via /storage
|
# local: write to server/storage and serve via /storage
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script src="%BASE_URL%model-library-config.js"></script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
9
web/public/model-library-config.js
Normal file
9
web/public/model-library-config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
window.__DMT_MODEL_LIBRARY_CONFIG__ = {
|
||||||
|
// 后端服务根地址。为空时使用当前页面域名,例如 /api/models。
|
||||||
|
// 示例:"https://model-api.example.com"
|
||||||
|
API_BASE_URL: "https://dmt.meswork.com/modelLibrary",
|
||||||
|
|
||||||
|
// 模型文件和缩略图服务根地址。为空时优先复用 API_BASE_URL。
|
||||||
|
// 示例:"https://model-api.example.com"
|
||||||
|
STORAGE_BASE_URL: "https://dmt.meswork.com"
|
||||||
|
};
|
||||||
52
web/src/config/runtime.ts
Normal file
52
web/src/config/runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
type RuntimeConfig = {
|
||||||
|
API_BASE_URL?: string;
|
||||||
|
STORAGE_BASE_URL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__DMT_MODEL_LIBRARY_CONFIG__?: RuntimeConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeConfig() {
|
||||||
|
return window.__DMT_MODEL_LIBRARY_CONFIG__ ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanBaseUrl(value?: string) {
|
||||||
|
return value?.trim().replace(/\/+$/g, "") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteUrl(url: string) {
|
||||||
|
return /^https?:\/\//i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(baseUrl: string, path: string) {
|
||||||
|
if (!baseUrl || isAbsoluteUrl(path)) return path;
|
||||||
|
return `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinApiUrl(baseUrl: string, path: string) {
|
||||||
|
if (!baseUrl || isAbsoluteUrl(path)) return path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
if (base.pathname.replace(/\/+$/g, "") === "/api" && path.startsWith("/api/")) {
|
||||||
|
return joinUrl(baseUrl, path.slice(4));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非完整 URL 时按普通路径拼接。
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinUrl(baseUrl, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApiUrl(path: string) {
|
||||||
|
return joinApiUrl(cleanBaseUrl(runtimeConfig().API_BASE_URL), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStorageUrl(path: string) {
|
||||||
|
const storageBaseUrl = cleanBaseUrl(runtimeConfig().STORAGE_BASE_URL);
|
||||||
|
const apiBaseUrl = cleanBaseUrl(runtimeConfig().API_BASE_URL);
|
||||||
|
return joinUrl(storageBaseUrl || apiBaseUrl, path);
|
||||||
|
}
|
||||||
@@ -115,23 +115,24 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card {
|
.model-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d7e0e8;
|
border: 1px solid #d7e0e8;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 150px;
|
aspect-ratio: 4 / 3;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
/* background:
|
/* background:
|
||||||
@@ -151,8 +152,9 @@
|
|||||||
.thumb-actions {
|
.thumb-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, minmax(0, 86px));
|
||||||
|
align-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -171,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb-actions button {
|
.thumb-actions button {
|
||||||
width: min(86px, 80%);
|
width: 100%;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb img {
|
.thumb img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
|||||||
@@ -64,6 +64,105 @@
|
|||||||
color: #405469;
|
color: #405469;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-file-summary {
|
||||||
|
display: block;
|
||||||
|
color: #1f6f8b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-list {
|
||||||
|
display: block;
|
||||||
|
max-height: 58px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #405469;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #b9d7c1;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef8f1;
|
||||||
|
color: #27613a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-popup-form.is-uploading .drop-zone {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-panel {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
border: 1px solid #dbe5ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-title {
|
||||||
|
color: #1c2733;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-message {
|
||||||
|
color: #405469;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-track {
|
||||||
|
height: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e8eef3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-bar {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #1f6f8b;
|
||||||
|
transition: width 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-meta {
|
||||||
|
color: #27613a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-file {
|
||||||
|
max-height: 48px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #647586;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-target-path {
|
.upload-target-path {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 64px minmax(0, 1fr);
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
@@ -106,7 +205,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: #f4f7f9;
|
background: #f4f7f9;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1fr;
|
grid-template-columns: 250px 1fr;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -134,7 +233,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 0 10px;
|
||||||
border-bottom: 1px solid #d8e0e8;
|
border-bottom: 1px solid #d8e0e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +249,7 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-model-meta {
|
.preview-model-meta {
|
||||||
@@ -211,6 +310,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-process-status {
|
.preview-process-status {
|
||||||
|
display: none;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
max-height: 78px;
|
max-height: 78px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -235,6 +335,16 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-scene-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scene-actions > button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-scene-body > button {
|
.preview-scene-body > button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function renderApp() {
|
|||||||
const currentUser = getCurrentUser();
|
const currentUser = getCurrentUser();
|
||||||
const isAdmin = currentUser?.role === "admin";
|
const isAdmin = currentUser?.role === "admin";
|
||||||
const isFixedLogin = document.body.dataset.authMode === "fixed";
|
const isFixedLogin = document.body.dataset.authMode === "fixed";
|
||||||
|
const canManageUsers = isAdmin && !isFixedLogin;
|
||||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -32,10 +33,10 @@ export async function renderApp() {
|
|||||||
<main class="content-pane">
|
<main class="content-pane">
|
||||||
<div class="content-toolbar">
|
<div class="content-toolbar">
|
||||||
<div class="content-actions">
|
<div class="content-actions">
|
||||||
${isAdmin ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
|
${canManageUsers ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
|
||||||
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
|
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
|
||||||
${isAdmin ? `<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>` : ""}
|
${canManageUsers ? `<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>` : ""}
|
||||||
${isAdmin ? `<button id="addModelBtn1" class="primary-btn" type="button" hidden>增加模型</button>` : ""}
|
${canManageUsers ? `<button id="addModelBtn1" class="primary-btn" type="button" hidden>增加模型</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-toolbar">
|
<div class="filter-toolbar">
|
||||||
@@ -55,8 +56,8 @@ export async function renderApp() {
|
|||||||
<span>关键字</span>
|
<span>关键字</span>
|
||||||
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
||||||
</label>
|
</label>
|
||||||
<button id="resetFilterBtn" type="button">重置</button>
|
<button id="resetFilterBtn" type="button">筛选模型</button>
|
||||||
<button id="addModelBtn" class="primary-btn" type="button">增加模型</button>
|
<button id="addModelBtn" class="primary-btn" type="button">导入模型</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="modelGrid" class="model-grid"></div>
|
<div id="modelGrid" class="model-grid"></div>
|
||||||
<footer id="modelPagination" class="pagination"></footer>
|
<footer id="modelPagination" class="pagination"></footer>
|
||||||
|
|||||||
@@ -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");
|
const result = await api<FolderTreeResponse>("/api/folders");
|
||||||
appState.folders = result.folders;
|
appState.folders = result.folders;
|
||||||
const root = appState.folders.find((folder) => folder.parent_id === null);
|
const root = appState.folders.find((folder) => folder.parent_id === null);
|
||||||
@@ -125,8 +126,10 @@ export async function loadFolders() {
|
|||||||
|
|
||||||
if (appState.selectedFolderId) {
|
if (appState.selectedFolderId) {
|
||||||
$("#folderTree").on("ready.jstree", () => {
|
$("#folderTree").on("ready.jstree", () => {
|
||||||
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId));
|
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId), true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await loadModels();
|
if (reloadModels) {
|
||||||
|
await loadModels();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { renderPagination } from "../../../components/pagination";
|
|||||||
import { getCurrentUser } from "../../../services/authState";
|
import { getCurrentUser } from "../../../services/authState";
|
||||||
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
|
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
|
||||||
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
||||||
|
import { downloadFileFromUrl } from "../../../utils/fileDownload";
|
||||||
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
||||||
|
import { resolveStorageUrl } from "../../../config/runtime";
|
||||||
import { appState } from "../appState";
|
import { appState } from "../appState";
|
||||||
|
import { generateModelThumbnailFromFile } from "./thumbnail";
|
||||||
|
|
||||||
type UploadFormState = {
|
type UploadFormState = {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
|
files: File[];
|
||||||
|
thumbnailFailures: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UploadModelPayload = {
|
type UploadModelPayload = {
|
||||||
@@ -21,6 +26,10 @@ type UploadModelPayload = {
|
|||||||
properties?: Record<string, string>;
|
properties?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UploadModelResponse = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||||
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
|
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
|
||||||
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
|
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
|
||||||
@@ -35,6 +44,7 @@ type SceneModelMessagePayload = {
|
|||||||
|
|
||||||
type SceneModelMessage = {
|
type SceneModelMessage = {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
requestId?: string;
|
||||||
payload?: SceneModelMessagePayload;
|
payload?: SceneModelMessagePayload;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
@@ -42,6 +52,9 @@ type SceneModelMessage = {
|
|||||||
let dictionariesLoaded = false;
|
let dictionariesLoaded = false;
|
||||||
let dictionariesLoading: Promise<void> | null = null;
|
let dictionariesLoading: Promise<void> | null = null;
|
||||||
let sceneModelBridgeBound = false;
|
let sceneModelBridgeBound = false;
|
||||||
|
let pendingSceneModelRequestId = "";
|
||||||
|
let pendingSceneModelRequestTimer: number | null = null;
|
||||||
|
let visibleModels = new Map<number, ModelItem>();
|
||||||
|
|
||||||
export function bindModelActions() {
|
export function bindModelActions() {
|
||||||
const isAdmin = getCurrentUser()?.role === "admin";
|
const isAdmin = getCurrentUser()?.role === "admin";
|
||||||
@@ -120,7 +133,7 @@ export function bindModelActions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadModels() {
|
export async function loadModels(options: { preserveCards?: boolean } = {}) {
|
||||||
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
|
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
|
||||||
if (!grid || !appState.selectedFolderId) return;
|
if (!grid || !appState.selectedFolderId) return;
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
@@ -150,37 +163,89 @@ export async function loadModels() {
|
|||||||
await loadModels();
|
await loadModels();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
|
visibleModels = new Map(result.items.map((item) => [item.id, item]));
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => {
|
renderModelGrid(grid, result.items, options.preserveCards);
|
||||||
const model = result.items.find((item) => item.id === Number(button.dataset.id));
|
bindModelGridActions(grid);
|
||||||
if (model) {
|
}
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
|
function bindModelGridActions(grid: HTMLDivElement) {
|
||||||
|
if (grid.dataset.actionsBound === "true") return;
|
||||||
|
grid.dataset.actionsBound = "true";
|
||||||
|
grid.addEventListener("click", async (event) => {
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
const button = target?.closest<HTMLButtonElement>("button[data-action][data-id]");
|
||||||
|
if (!button || !grid.contains(button)) return;
|
||||||
|
const id = Number(button.dataset.id);
|
||||||
|
const model = visibleModels.get(id);
|
||||||
|
try {
|
||||||
|
switch (button.dataset.action) {
|
||||||
|
case "preview": {
|
||||||
|
if (!model) return;
|
||||||
const { openModelPreview } = await import("./preview");
|
const { openModelPreview } = await import("./preview");
|
||||||
openModelPreview(model, loadModels);
|
openModelPreview(model, loadModels);
|
||||||
});
|
break;
|
||||||
|
}
|
||||||
|
case "edit":
|
||||||
|
await editModel(id);
|
||||||
|
break;
|
||||||
|
case "import": {
|
||||||
|
if (!model) return;
|
||||||
|
const { createImportScenePayload, emitImportScenePayload } = await import("./preview");
|
||||||
|
const payload = createImportScenePayload({
|
||||||
|
model,
|
||||||
|
basePointEnabled: false,
|
||||||
|
basePoint: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 },
|
||||||
|
selectedOperationIndex: null
|
||||||
|
});
|
||||||
|
emitImportScenePayload(payload);
|
||||||
|
notify("导入场景数据已输出");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "download":
|
||||||
|
if (!model) return;
|
||||||
|
await downloadFileFromUrl(model.file_url, model.original_filename || model.name);
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await deleteModel(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => {
|
}
|
||||||
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
|
|
||||||
});
|
function renderModelGrid(grid: HTMLDivElement, items: ModelItem[], preserveCards = false) {
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
|
if (!preserveCards) {
|
||||||
const model = result.items.find((item) => item.id === Number(button.dataset.id));
|
grid.innerHTML = items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
|
||||||
if (!model) return;
|
return;
|
||||||
button.addEventListener("click", async () => {
|
}
|
||||||
const { createImportScenePayload, emitImportScenePayload } = await import("./preview");
|
if (items.length === 0) {
|
||||||
const payload = createImportScenePayload({
|
grid.innerHTML = `<div class="empty-state">当前目录暂无模型</div>`;
|
||||||
model,
|
return;
|
||||||
basePointEnabled: false,
|
}
|
||||||
basePoint: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 },
|
const existingCards = new Map(
|
||||||
selectedOperationIndex: null
|
Array.from(grid.querySelectorAll<HTMLElement>(".model-card[data-model-id]"))
|
||||||
});
|
.map((card) => [Number(card.dataset.modelId), card])
|
||||||
emitImportScenePayload(payload);
|
);
|
||||||
notify("导入场景数据已输出");
|
const fragment = document.createDocumentFragment();
|
||||||
});
|
for (const item of items) {
|
||||||
});
|
const existingCard = existingCards.get(item.id);
|
||||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
|
if (existingCard) {
|
||||||
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
|
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) {
|
async function loadDictionaries(force = false) {
|
||||||
@@ -230,7 +295,7 @@ function renderModelCard(item: ModelItem) {
|
|||||||
const canWrite = item.permissions.write;
|
const canWrite = item.permissions.write;
|
||||||
const prop = item.properties ?? {};
|
const prop = item.properties ?? {};
|
||||||
const thumb = item.thumbnail_url
|
const thumb = item.thumbnail_url
|
||||||
? `<img src="${item.thumbnail_url}" alt="" />`
|
? `<img src="${resolveStorageUrl(item.thumbnail_url)}" alt="" />`
|
||||||
: `<div class="thumb-placeholder">暂无预览图</div>`;
|
: `<div class="thumb-placeholder">暂无预览图</div>`;
|
||||||
return `
|
return `
|
||||||
<article class="model-card" data-model-id="${item.id}">
|
<article class="model-card" data-model-id="${item.id}">
|
||||||
@@ -241,6 +306,7 @@ function renderModelCard(item: ModelItem) {
|
|||||||
${thumb}
|
${thumb}
|
||||||
<div class="thumb-actions">
|
<div class="thumb-actions">
|
||||||
<button data-action="preview" data-id="${item.id}">预览</button>
|
<button data-action="preview" data-id="${item.id}">预览</button>
|
||||||
|
<button data-action="download" data-id="${item.id}">下载</button>
|
||||||
${canWrite ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
${canWrite ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
||||||
<button data-action="import" data-id="${item.id}">导入</button>
|
<button data-action="import" data-id="${item.id}">导入</button>
|
||||||
${canWrite ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
${canWrite ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
||||||
@@ -250,8 +316,6 @@ function renderModelCard(item: ModelItem) {
|
|||||||
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
|
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
|
||||||
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
|
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
|
||||||
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
|
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
|
||||||
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
|
|
||||||
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
@@ -300,13 +364,27 @@ function requestSceneModelImport() {
|
|||||||
notify("请在 DMT 主程序中使用导入场景模型");
|
notify("请在 DMT 主程序中使用导入场景模型");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestId = createSceneModelRequestId();
|
||||||
|
pendingSceneModelRequestId = requestId;
|
||||||
|
clearSceneModelRequestTimer();
|
||||||
|
pendingSceneModelRequestTimer = window.setTimeout(() => {
|
||||||
|
if (pendingSceneModelRequestId !== requestId) return;
|
||||||
|
pendingSceneModelRequestId = "";
|
||||||
|
pendingSceneModelRequestTimer = null;
|
||||||
|
notifyError("主程序未响应导入场景模型请求,请确认模型库是在主程序面板中打开");
|
||||||
|
}, 15000);
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: requestSceneModelMessageType
|
type: requestSceneModelMessageType,
|
||||||
}, resolveParentOrigin());
|
requestId
|
||||||
notify("已请求主程序导出当前选中模型");
|
}, resolveParentPostMessageOrigin());
|
||||||
|
// notify("已请求主程序导出当前选中模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSceneModelMessage(data: SceneModelMessage) {
|
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) {
|
if (data.type === sceneModelErrorMessageType) {
|
||||||
notifyError(data.message || "主程序导出模型失败");
|
notifyError(data.message || "主程序导出模型失败");
|
||||||
return;
|
return;
|
||||||
@@ -327,9 +405,24 @@ async function handleSceneModelMessage(data: SceneModelMessage) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function isAllowedParentOrigin(origin: string) {
|
||||||
const expectedOrigin = resolveParentOrigin();
|
const expectedOrigin = resolveParentOrigin();
|
||||||
return expectedOrigin === "*" || origin === expectedOrigin;
|
return expectedOrigin === "*" || origin === expectedOrigin || isLocalDmtParentOrigin(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentPostMessageOrigin() {
|
||||||
|
const origin = resolveParentOrigin();
|
||||||
|
return origin === window.location.origin ? "*" : origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveParentOrigin() {
|
function resolveParentOrigin() {
|
||||||
@@ -344,6 +437,16 @@ function resolveParentOrigin() {
|
|||||||
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) {
|
function ensureGlbFileName(fileName: string) {
|
||||||
return fileName.toLowerCase().endsWith(".glb") ? fileName : `${fileName}.glb`;
|
return fileName.toLowerCase().endsWith(".glb") ? fileName : `${fileName}.glb`;
|
||||||
}
|
}
|
||||||
@@ -364,7 +467,7 @@ export async function uploadModelToBackend(payload: UploadModelPayload) {
|
|||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("folderId", String(folderId));
|
form.set("folderId", String(folderId));
|
||||||
form.set("name", modelNameFromFile(displayName));
|
form.set("name", displayName);
|
||||||
form.set("brandName", payload.brandName ?? "");
|
form.set("brandName", payload.brandName ?? "");
|
||||||
form.set("typeName", payload.typeName ?? "");
|
form.set("typeName", payload.typeName ?? "");
|
||||||
form.set("operationTree", normalizeOperationTreeInput(payload.operationTree));
|
form.set("operationTree", normalizeOperationTreeInput(payload.operationTree));
|
||||||
@@ -373,10 +476,11 @@ export async function uploadModelToBackend(payload: UploadModelPayload) {
|
|||||||
form.set(`prop.${key}`, value);
|
form.set(`prop.${key}`, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
await api("/api/models/upload", {
|
const result = await api<UploadModelResponse>("/api/models/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form
|
body: form
|
||||||
});
|
});
|
||||||
|
return result.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openUploadModelDialog() {
|
async function openUploadModelDialog() {
|
||||||
@@ -385,21 +489,21 @@ async function openUploadModelDialog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: UploadFormState = { file: null };
|
const state: UploadFormState = { file: null, files: [], thumbnailFailures: [] };
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
const result = await formDialog<boolean>({
|
const result = await formDialog<boolean>({
|
||||||
title: "增加模型",
|
title: "增加模型",
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 520,
|
height: 430,
|
||||||
blockPage: false,
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
|
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
|
||||||
<label>
|
<label>
|
||||||
<span>模型文件</span>
|
<span>模型文件</span>
|
||||||
<div id="modelDropZone" class="drop-zone">
|
<div id="modelDropZone" class="drop-zone">
|
||||||
<input id="modelUploadFile" type="file" accept=".glb" />
|
<input id="modelUploadFile" type="file" accept=".glb" multiple />
|
||||||
<strong>选择模型</strong>
|
<strong>选择模型</strong>
|
||||||
<em>或拖拽 .glb 模型到这里</em>
|
<em>或拖拽一个或多个 .glb 模型到这里</em>
|
||||||
<small id="selectedFileName">未选择文件</small>
|
<small id="selectedFileName">未选择文件</small>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -411,42 +515,95 @@ async function openUploadModelDialog() {
|
|||||||
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
||||||
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
||||||
<label><span>型号</span><input name="model" /></label>
|
<label><span>型号</span><input name="model" /></label>
|
||||||
<label><span>价钱</span><input name="price" /></label>
|
|
||||||
<label><span>重量</span><input name="weight" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modelUploadStatus" class="upload-status" aria-live="polite" hidden></div>
|
||||||
${dictionaryDatalistHtml()}
|
${dictionaryDatalistHtml()}
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
onOpen: () => bindUploadDialogEvents(state),
|
onOpen: () => bindUploadDialogEvents(state),
|
||||||
onSubmit: async () => {
|
onSubmit: async () => {
|
||||||
if (!state.file) {
|
if (state.files.length === 0) {
|
||||||
throw new Error("请选择 .glb 模型文件");
|
throw new Error("请选择 .glb 模型文件");
|
||||||
}
|
}
|
||||||
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
||||||
if (!name) {
|
if (state.files.length === 1 && !name) {
|
||||||
throw new Error("模型名称不能为空");
|
throw new Error("模型名称不能为空");
|
||||||
}
|
}
|
||||||
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
||||||
await uploadModelToBackend({
|
const brandName = String(form.get("brandName") ?? "");
|
||||||
file: state.file,
|
const typeName = String(form.get("typeName") ?? "");
|
||||||
name,
|
const properties = {
|
||||||
fileName: state.file.name,
|
model: String(form.get("model") ?? "")
|
||||||
brandName: String(form.get("brandName") ?? ""),
|
};
|
||||||
typeName: String(form.get("typeName") ?? ""),
|
let completedCount = 0;
|
||||||
operationTree: defaultOperationTree,
|
const totalCount = state.files.length;
|
||||||
properties: {
|
setUploadDialogBusy(state, true);
|
||||||
model: String(form.get("model") ?? ""),
|
showUploadProgressOverlay(totalCount);
|
||||||
price: String(form.get("price") ?? ""),
|
try {
|
||||||
weight: String(form.get("weight") ?? "")
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
|
if (state.thumbnailFailures.length > 0) {
|
||||||
|
notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`);
|
||||||
|
} else {
|
||||||
|
notify("模型上传和预览图生成完成");
|
||||||
|
}
|
||||||
invalidateDictionaries();
|
invalidateDictionaries();
|
||||||
await loadModels();
|
await reloadFoldersAndModels();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,44 +617,22 @@ export async function openProcessModelUploadDialog(input?: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: UploadFormState = { file: input?.file ?? null };
|
const state: UploadFormState = { file: input?.file ?? null, files: input?.file ? [input.file] : [], thumbnailFailures: [] };
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
|
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
|
||||||
const result = await formDialog<boolean>({
|
const result = await formDialog<boolean>({
|
||||||
title: "上传工艺模型",
|
title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}】`,
|
||||||
width: 640,
|
width: 400,
|
||||||
height: 650,
|
height: 300,
|
||||||
blockPage: false,
|
blockPage: false,
|
||||||
body: `
|
body: `
|
||||||
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form">
|
<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">
|
<div class="popup-form-grid">
|
||||||
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
<label><span>模型名称</span><input id="processModelUploadName" name="name" value="${escapeHtml(initialName)}" autocomplete="off" /></label>
|
||||||
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
<label><span>品牌</span><input name="brandName" list="brandOptions" autocomplete="off" /></label>
|
||||||
<label><span>型号</span><input name="model" /></label>
|
<label><span>类型</span><input name="typeName" list="typeOptions" autocomplete="off" /></label>
|
||||||
<label><span>价钱</span><input name="price" /></label>
|
<label><span>型号</span><input name="model" autocomplete="off" /></label>
|
||||||
<label><span>重量</span><input name="weight" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
<label>
|
|
||||||
<span>工艺数据 JSON</span>
|
|
||||||
<textarea id="processOperationTreeJson" name="operationTree" placeholder='可以粘贴 {"OperationTree":"[...]"} 或 OperationTree[] 数组 JSON'>${escapeHtml(input?.operationTreeJson ?? "")}</textarea>
|
|
||||||
</label>
|
|
||||||
${dictionaryDatalistHtml()}
|
${dictionaryDatalistHtml()}
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
@@ -508,7 +643,7 @@ export async function openProcessModelUploadDialog(input?: {
|
|||||||
}
|
}
|
||||||
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
|
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
|
||||||
const name = String(form.get("name") ?? "").trim();
|
const name = String(form.get("name") ?? "").trim();
|
||||||
await uploadModelToBackend({
|
const modelId = await uploadModelToBackend({
|
||||||
file: state.file,
|
file: state.file,
|
||||||
name,
|
name,
|
||||||
fileName: state.file.name,
|
fileName: state.file.name,
|
||||||
@@ -516,18 +651,21 @@ export async function openProcessModelUploadDialog(input?: {
|
|||||||
typeName: String(form.get("typeName") ?? ""),
|
typeName: String(form.get("typeName") ?? ""),
|
||||||
operationTree: String(form.get("operationTree") ?? ""),
|
operationTree: String(form.get("operationTree") ?? ""),
|
||||||
properties: {
|
properties: {
|
||||||
model: String(form.get("model") ?? ""),
|
model: String(form.get("model") ?? "")
|
||||||
price: String(form.get("price") ?? ""),
|
|
||||||
weight: String(form.get("weight") ?? "")
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await generateAndSaveModelThumbnail(modelId, state.file);
|
||||||
|
} catch {
|
||||||
|
notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
invalidateDictionaries();
|
invalidateDictionaries();
|
||||||
await loadModels();
|
await reloadFoldersAndModels();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,21 +675,48 @@ function bindUploadDialogEvents(state: UploadFormState) {
|
|||||||
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
||||||
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
|
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
|
||||||
|
|
||||||
const selectFile = (file: File) => {
|
const renderSelectedFiles = () => {
|
||||||
if (!file.name.toLowerCase().endsWith(".glb")) {
|
if (state.files.length === 0) {
|
||||||
|
selectedFileName.textContent = "未选择文件";
|
||||||
|
nameInput.disabled = false;
|
||||||
|
nameInput.value = "";
|
||||||
|
nameInput.placeholder = "选择文件后自动填入";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.files.length === 1) {
|
||||||
|
const [file] = state.files;
|
||||||
|
selectedFileName.textContent = file.name;
|
||||||
|
nameInput.disabled = false;
|
||||||
|
nameInput.value = modelNameFromFile(file.name);
|
||||||
|
nameInput.placeholder = "选择文件后自动填入";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFileName.innerHTML = `
|
||||||
|
<span class="selected-file-summary">已选择 ${state.files.length} 个模型,名称按文件名自动生成</span>
|
||||||
|
<span class="selected-file-list">${state.files.map((file) => escapeHtml(file.name)).join("<br>")}</span>
|
||||||
|
`;
|
||||||
|
nameInput.disabled = true;
|
||||||
|
nameInput.value = `${state.files.length} 个模型按文件名自动命名`;
|
||||||
|
nameInput.placeholder = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFiles = (files: FileList | File[]) => {
|
||||||
|
const selectedFiles = Array.from(files);
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
const invalidFiles = selectedFiles.filter((file) => !file.name.toLowerCase().endsWith(".glb"));
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
notify("当前阶段只允许上传 .glb 模型");
|
notify("当前阶段只允许上传 .glb 模型");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.file = file;
|
state.files = selectedFiles;
|
||||||
selectedFileName.textContent = file.name;
|
state.file = selectedFiles[0] ?? null;
|
||||||
if (!nameInput.value.trim()) {
|
renderSelectedFiles();
|
||||||
nameInput.value = modelNameFromFile(file.name);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fileInput.addEventListener("change", () => {
|
fileInput.addEventListener("change", () => {
|
||||||
const file = fileInput.files?.[0];
|
if (fileInput.files) selectFiles(fileInput.files);
|
||||||
if (file) selectFile(file);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener("click", (event) => {
|
dropZone.addEventListener("click", (event) => {
|
||||||
@@ -568,58 +733,140 @@ function bindUploadDialogEvents(state: UploadFormState) {
|
|||||||
dropZone.addEventListener("drop", (event) => {
|
dropZone.addEventListener("drop", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dropZone.classList.remove("is-dragover");
|
dropZone.classList.remove("is-dragover");
|
||||||
const file = event.dataTransfer?.files?.[0];
|
const files = event.dataTransfer?.files;
|
||||||
if (file) selectFile(file);
|
if (files) selectFiles(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadStatus(message: string) {
|
||||||
|
const status = document.querySelector<HTMLElement>("#modelUploadStatus");
|
||||||
|
if (!status) return;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForUploadStatusPaint() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadProgressOverlay(total: number) {
|
||||||
|
let overlay = document.querySelector<HTMLElement>("#modelUploadProgressOverlay");
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement("div");
|
||||||
|
overlay.id = "modelUploadProgressOverlay";
|
||||||
|
overlay.className = "upload-progress-overlay";
|
||||||
|
overlay.setAttribute("role", "status");
|
||||||
|
overlay.setAttribute("aria-live", "polite");
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="upload-progress-panel">
|
||||||
|
<div class="upload-progress-title">模型上传中</div>
|
||||||
|
<div id="modelUploadProgressMessage" class="upload-progress-message">正在准备上传...</div>
|
||||||
|
<div class="upload-progress-track">
|
||||||
|
<div id="modelUploadProgressBar" class="upload-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="modelUploadProgressMeta" class="upload-progress-meta">0/${total}</div>
|
||||||
|
<div id="modelUploadProgressFile" class="upload-progress-file"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.hidden = false;
|
||||||
|
updateUploadProgressOverlay({
|
||||||
|
total,
|
||||||
|
completed: 0,
|
||||||
|
message: "正在准备上传...",
|
||||||
|
fileName: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadProgressOverlay(options: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
message: string;
|
||||||
|
fileName: string;
|
||||||
|
}) {
|
||||||
|
const message = document.querySelector<HTMLElement>("#modelUploadProgressMessage");
|
||||||
|
const meta = document.querySelector<HTMLElement>("#modelUploadProgressMeta");
|
||||||
|
const file = document.querySelector<HTMLElement>("#modelUploadProgressFile");
|
||||||
|
const bar = document.querySelector<HTMLElement>("#modelUploadProgressBar");
|
||||||
|
const percent = options.total > 0 ? Math.round((options.completed / options.total) * 100) : 0;
|
||||||
|
if (message) message.textContent = options.message;
|
||||||
|
if (meta) meta.textContent = `${options.completed}/${options.total}`;
|
||||||
|
if (file) file.textContent = options.fileName;
|
||||||
|
if (bar) bar.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUploadProgressOverlay() {
|
||||||
|
const overlay = document.querySelector<HTMLElement>("#modelUploadProgressOverlay");
|
||||||
|
if (overlay) overlay.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAndSaveModelThumbnail(modelId: number, file: File) {
|
||||||
|
const thumbnail = await generateModelThumbnailFromFile(file);
|
||||||
|
await api(`/api/models/${modelId}/thumbnail`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ thumbnail })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadDialogBusy(state: UploadFormState, busy: boolean) {
|
||||||
|
const form = document.querySelector<HTMLFormElement>("#modelUploadPopupForm");
|
||||||
|
if (!form) return;
|
||||||
|
form.classList.toggle("is-uploading", busy);
|
||||||
|
form.querySelectorAll<HTMLInputElement>("input").forEach((input) => {
|
||||||
|
input.disabled = busy || (input.id === "modelUploadName" && state.files.length > 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindProcessUploadDialogEvents(state: UploadFormState) {
|
function bindProcessUploadDialogEvents(state: UploadFormState) {
|
||||||
const dropZone = document.querySelector<HTMLDivElement>("#processModelDropZone")!;
|
// const dropZone = document.querySelector<HTMLDivElement>("#processModelDropZone")!;
|
||||||
const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!;
|
// const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!;
|
||||||
const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!;
|
// const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!;
|
||||||
const selectedFileName = document.querySelector<HTMLElement>("#processSelectedFileName")!;
|
// const selectedFileName = document.querySelector<HTMLElement>("#processSelectedFileName")!;
|
||||||
|
|
||||||
const selectFile = (file: File) => {
|
// const selectFile = (file: File) => {
|
||||||
if (!file.name.toLowerCase().endsWith(".glb")) {
|
// if (!file.name.toLowerCase().endsWith(".glb")) {
|
||||||
notify("当前阶段只允许上传 .glb 模型");
|
// notify("当前阶段只允许上传 .glb 模型");
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
state.file = file;
|
// state.file = file;
|
||||||
selectedFileName.textContent = file.name;
|
// selectedFileName.textContent = file.name;
|
||||||
if (!nameInput.value.trim()) {
|
// if (!nameInput.value.trim()) {
|
||||||
nameInput.value = file.name;
|
// nameInput.value = file.name;
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
if (state.file) {
|
// if (state.file) {
|
||||||
selectedFileName.textContent = state.file.name;
|
// selectedFileName.textContent = state.file.name;
|
||||||
if (!nameInput.value.trim()) {
|
// if (!nameInput.value.trim()) {
|
||||||
nameInput.value = state.file.name;
|
// nameInput.value = state.file.name;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
fileInput.addEventListener("change", () => {
|
// fileInput.addEventListener("change", () => {
|
||||||
const file = fileInput.files?.[0];
|
// const file = fileInput.files?.[0];
|
||||||
if (file) selectFile(file);
|
// if (file) selectFile(file);
|
||||||
});
|
// });
|
||||||
|
|
||||||
dropZone.addEventListener("click", (event) => {
|
// dropZone.addEventListener("click", (event) => {
|
||||||
if (event.target !== fileInput) fileInput.click();
|
// if (event.target !== fileInput) fileInput.click();
|
||||||
});
|
// });
|
||||||
|
|
||||||
dropZone.addEventListener("dragover", (event) => {
|
// dropZone.addEventListener("dragover", (event) => {
|
||||||
event.preventDefault();
|
// event.preventDefault();
|
||||||
dropZone.classList.add("is-dragover");
|
// dropZone.classList.add("is-dragover");
|
||||||
});
|
// });
|
||||||
dropZone.addEventListener("dragleave", () => {
|
// dropZone.addEventListener("dragleave", () => {
|
||||||
dropZone.classList.remove("is-dragover");
|
// dropZone.classList.remove("is-dragover");
|
||||||
});
|
// });
|
||||||
dropZone.addEventListener("drop", (event) => {
|
// dropZone.addEventListener("drop", (event) => {
|
||||||
event.preventDefault();
|
// event.preventDefault();
|
||||||
dropZone.classList.remove("is-dragover");
|
// dropZone.classList.remove("is-dragover");
|
||||||
const file = event.dataTransfer?.files?.[0];
|
// const file = event.dataTransfer?.files?.[0];
|
||||||
if (file) selectFile(file);
|
// if (file) selectFile(file);
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOperationTreeInput(value?: string) {
|
function normalizeOperationTreeInput(value?: string) {
|
||||||
@@ -673,7 +920,7 @@ async function editModel(id: number) {
|
|||||||
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
|
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
|
||||||
const oldName = card?.querySelector("strong")?.textContent ?? "";
|
const oldName = card?.querySelector("strong")?.textContent ?? "";
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string; price: string; weight: string }>({
|
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string }>({
|
||||||
title: "编辑模型",
|
title: "编辑模型",
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 390,
|
height: 390,
|
||||||
@@ -685,8 +932,6 @@ async function editModel(id: number) {
|
|||||||
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
|
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
|
||||||
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
|
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
|
||||||
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
|
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
|
||||||
<label><span>价钱</span><input name="price" value="${escapeHtml(card?.querySelector("dl div:nth-child(4) dd")?.textContent ?? "")}" /></label>
|
|
||||||
<label><span>重量</span><input name="weight" value="${escapeHtml(card?.querySelector("dl div:nth-child(5) dd")?.textContent ?? "")}" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
${dictionaryDatalistHtml()}
|
${dictionaryDatalistHtml()}
|
||||||
</form>
|
</form>
|
||||||
@@ -701,9 +946,7 @@ async function editModel(id: number) {
|
|||||||
name,
|
name,
|
||||||
brandName: String(form.get("brandName") ?? ""),
|
brandName: String(form.get("brandName") ?? ""),
|
||||||
typeName: String(form.get("typeName") ?? ""),
|
typeName: String(form.get("typeName") ?? ""),
|
||||||
model: String(form.get("model") ?? ""),
|
model: String(form.get("model") ?? "")
|
||||||
price: String(form.get("price") ?? ""),
|
|
||||||
weight: String(form.get("weight") ?? "")
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -715,9 +958,7 @@ async function editModel(id: number) {
|
|||||||
brandName: result.brandName,
|
brandName: result.brandName,
|
||||||
typeName: result.typeName,
|
typeName: result.typeName,
|
||||||
properties: {
|
properties: {
|
||||||
model: result.model,
|
model: result.model
|
||||||
price: result.price,
|
|
||||||
weight: result.weight
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -740,5 +981,5 @@ async function deleteModel(id: number) {
|
|||||||
const confirmed = await confirmDialog("确认删除该模型?");
|
const confirmed = await confirmDialog("确认删除该模型?");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
await api(`/api/models/${id}`, { method: "DELETE" });
|
await api(`/api/models/${id}`, { method: "DELETE" });
|
||||||
await loadModels();
|
await reloadFoldersAndModels(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|||||||
import type { ModelItem } from "../../../types";
|
import type { ModelItem } from "../../../types";
|
||||||
import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree";
|
import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree";
|
||||||
import { P_OPERATION } from "../../../types/OPERATION_BaseClass";
|
import { P_OPERATION } from "../../../types/OPERATION_BaseClass";
|
||||||
|
import { downloadFileFromUrl } from "../../../utils/fileDownload";
|
||||||
import { escapeHtml, formatBytes } from "../../../utils/format";
|
import { escapeHtml, formatBytes } from "../../../utils/format";
|
||||||
import { notify, notifyError } from "../../../ui/dialogs";
|
import { notify, notifyError } from "../../../ui/dialogs";
|
||||||
import { api } from "../../../services/api";
|
import { api } from "../../../services/api";
|
||||||
|
import { resolveStorageUrl } from "../../../config/runtime";
|
||||||
|
|
||||||
type PreviewRuntime = {
|
type PreviewRuntime = {
|
||||||
renderer: WebGLRenderer;
|
renderer: WebGLRenderer;
|
||||||
@@ -36,6 +38,29 @@ type ParseableGltfLoader = GLTFLoader & {
|
|||||||
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
|
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 = {
|
export type PreviewBasePoint = {
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
@@ -72,10 +97,24 @@ type ParentImportScenePayload = {
|
|||||||
rawOperationTree: string;
|
rawOperationTree: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ParentBasePointMessage = {
|
||||||
|
type?: string;
|
||||||
|
message?: string;
|
||||||
|
payload?: {
|
||||||
|
modelId?: string;
|
||||||
|
modelName?: string;
|
||||||
|
basePoint?: PreviewBasePoint;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ModelPreviewOptions = {
|
export type ModelPreviewOptions = {
|
||||||
onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | void;
|
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 = {
|
const zeroBasePoint: PreviewResolvedBasePoint = {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -84,12 +123,22 @@ const zeroBasePoint: PreviewResolvedBasePoint = {
|
|||||||
ry: 0,
|
ry: 0,
|
||||||
rz: 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 previewEnvironmentUrl = "/DayCityOutdoor.exr";
|
||||||
|
const DEFAULT_PREVIEW_FRAME_INTERVAL_MS = 20;
|
||||||
|
const SECOND_MS = 1000;
|
||||||
|
|
||||||
let runtime: PreviewRuntime | null = null;
|
let runtime: PreviewRuntime | null = null;
|
||||||
let activePreviewContext: PreviewLoadContext | null = null;
|
let activePreviewContext: PreviewLoadContext | null = null;
|
||||||
let previewEnvironmentTexturePromise: Promise<Texture> | null = null;
|
let previewEnvironmentTexturePromise: Promise<Texture> | null = null;
|
||||||
|
let previewPlayback: PreviewPlaybackState = createEmptyPlaybackState();
|
||||||
|
let basePointBridgeBound = false;
|
||||||
|
let basePointSubscribed = false;
|
||||||
|
|
||||||
export function openModelPreview(
|
export function openModelPreview(
|
||||||
model: ModelItem,
|
model: ModelItem,
|
||||||
@@ -98,7 +147,7 @@ export function openModelPreview(
|
|||||||
) {
|
) {
|
||||||
const context = beginPreviewContext();
|
const context = beginPreviewContext();
|
||||||
const canWrite = model.permissions.write;
|
const canWrite = model.permissions.write;
|
||||||
const url = model.file_url;
|
const url = resolveStorageUrl(model.file_url);
|
||||||
const operations = parseModelOperations(model.operation_tree);
|
const operations = parseModelOperations(model.operation_tree);
|
||||||
const popup = w2popup.open({
|
const popup = w2popup.open({
|
||||||
title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`,
|
title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`,
|
||||||
@@ -127,25 +176,21 @@ export function openModelPreview(
|
|||||||
</div>
|
</div>
|
||||||
<div class="preview-scene-section">
|
<div class="preview-scene-section">
|
||||||
<div class="preview-scene-body">
|
<div class="preview-scene-body">
|
||||||
${canWrite ? `
|
<div class="preview-scene-actions">
|
||||||
<button id="previewCaptureThumbBtn" type="button">截缩略图</button>
|
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
|
||||||
<label class="preview-switch">
|
<label class="preview-switch">
|
||||||
<input id="previewTransparentThumb" type="checkbox" />
|
<input id="previewUseBasePoint" type="checkbox" />
|
||||||
<span>透明背景截图</span>
|
<span>启用基点</span>
|
||||||
</label>
|
</label>
|
||||||
` : ""}
|
</div>
|
||||||
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
|
|
||||||
<label class="preview-switch">
|
|
||||||
<input id="previewUseBasePoint" type="checkbox" />
|
|
||||||
<span>启用基点导入</span>
|
|
||||||
</label>
|
|
||||||
<div class="preview-basepoint-grid">
|
<div class="preview-basepoint-grid">
|
||||||
<label><span>X</span><input name="baseX" 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</span><input name="baseY" type="number" value="0" step="0.001" /></label>
|
<label><span>Y(mm)</span><input name="baseY" type="number" value="0.000" step="0.001" /></label>
|
||||||
<label><span>Z</span><input name="baseZ" type="number" value="0" 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" 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" 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" step="0.001" /></label>
|
<label><span>RZ(°)</span><input name="baseRz" type="number" value="0.000" step="0.001" /></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +203,16 @@ export function openModelPreview(
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
actions: {
|
actions: {
|
||||||
|
下载() {
|
||||||
|
bindDownloadModel(model);
|
||||||
|
},
|
||||||
|
更新缩略图() {
|
||||||
|
if (canWrite) {
|
||||||
|
screenshotModel(model.id, onThumbnailSaved).catch((error) => {
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
关闭() {
|
关闭() {
|
||||||
disposePreview();
|
disposePreview();
|
||||||
w2popup.close();
|
w2popup.close();
|
||||||
@@ -166,8 +221,10 @@ export function openModelPreview(
|
|||||||
});
|
});
|
||||||
|
|
||||||
popup.self
|
popup.self
|
||||||
.on("open:after", () => {
|
.on("open:after", () => {popup
|
||||||
|
console.log(popup)
|
||||||
bindProcessPlaceholder(operations);
|
bindProcessPlaceholder(operations);
|
||||||
|
bindBasePointSelection();
|
||||||
bindImportScene(model, operations, options.onImportScene);
|
bindImportScene(model, operations, options.onImportScene);
|
||||||
if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
|
if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -176,7 +233,10 @@ export function openModelPreview(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on("close:after", () => disposePreview());
|
.on("close:after", () => {
|
||||||
|
unsubscribeBasePointSelection();
|
||||||
|
disposePreview();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true) {
|
export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true) {
|
||||||
@@ -191,7 +251,7 @@ export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true)
|
|||||||
for (const [key, name] of fieldMap) {
|
for (const [key, name] of fieldMap) {
|
||||||
const input = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`);
|
const input = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`);
|
||||||
if (input && basePoint[key] !== undefined) {
|
if (input && basePoint[key] !== undefined) {
|
||||||
input.value = String(basePoint[key]);
|
input.value = formatBasePointDisplayValue(key, basePoint[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +288,7 @@ export function emitImportScenePayload(payload: PreviewImportScenePayload) {
|
|||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: "DMT_MODEL_LIBRARY_IMPORT_SCENE",
|
type: "DMT_MODEL_LIBRARY_IMPORT_SCENE",
|
||||||
payload: buildParentImportScenePayload(payload)
|
payload: buildParentImportScenePayload(payload)
|
||||||
}, resolveParentOrigin());
|
}, resolveParentPostMessageOrigin());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error);
|
notifyError(error);
|
||||||
}
|
}
|
||||||
@@ -241,7 +301,7 @@ function buildParentImportScenePayload(payload: PreviewImportScenePayload): Pare
|
|||||||
id: payload.model.id,
|
id: payload.model.id,
|
||||||
name: payload.model.name,
|
name: payload.model.name,
|
||||||
original_filename: payload.model.original_filename,
|
original_filename: payload.model.original_filename,
|
||||||
file_url: payload.model.file_url
|
file_url: resolveStorageUrl(payload.model.file_url)
|
||||||
},
|
},
|
||||||
modelUrl: payload.modelUrl,
|
modelUrl: payload.modelUrl,
|
||||||
basePointEnabled: payload.basePointEnabled,
|
basePointEnabled: payload.basePointEnabled,
|
||||||
@@ -250,6 +310,11 @@ function buildParentImportScenePayload(payload: PreviewImportScenePayload): Pare
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveParentPostMessageOrigin() {
|
||||||
|
const origin = resolveParentOrigin();
|
||||||
|
return origin === window.location.origin ? "*" : origin;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveParentOrigin() {
|
function resolveParentOrigin() {
|
||||||
try {
|
try {
|
||||||
const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> };
|
const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> };
|
||||||
@@ -262,6 +327,68 @@ function resolveParentOrigin() {
|
|||||||
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[] {
|
function parseModelOperations(value: string): PreviewOperation[] {
|
||||||
try {
|
try {
|
||||||
const dbValue = JSON.parse(value || "{}") as Partial<OperationTreeDB>;
|
const dbValue = JSON.parse(value || "{}") as Partial<OperationTreeDB>;
|
||||||
@@ -406,6 +533,7 @@ function isAbortError(error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupPreviewContextResources(context: PreviewLoadContext) {
|
function cleanupPreviewContextResources(context: PreviewLoadContext) {
|
||||||
|
stopPreviewPlayback();
|
||||||
if (context.animationId !== undefined) {
|
if (context.animationId !== undefined) {
|
||||||
cancelAnimationFrame(context.animationId);
|
cancelAnimationFrame(context.animationId);
|
||||||
context.animationId = undefined;
|
context.animationId = undefined;
|
||||||
@@ -454,40 +582,62 @@ function disposeMaterial(material: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
|
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 {
|
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) {
|
if (transparent) {
|
||||||
scene.background = null;
|
scene.background = null;
|
||||||
renderer.setClearColor(0x000000, 0);
|
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);
|
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) {
|
if (transparent) {
|
||||||
scene.background = oldBackground;
|
scene.background = oldBackground;
|
||||||
renderer.setClearAlpha(oldClearAlpha);
|
renderer.setClearAlpha(oldClearAlpha);
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
}
|
||||||
await api(`/api/models/${modelId}/thumbnail`, {
|
renderer.render(scene, camera);
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ thumbnail })
|
|
||||||
});
|
|
||||||
await onThumbnailSaved?.();
|
|
||||||
notify("缩略图已保存");
|
|
||||||
} catch (error) {
|
|
||||||
notifyError(error);
|
|
||||||
}
|
}
|
||||||
});
|
await api(`/api/models/${modelId}/thumbnail`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ thumbnail })
|
||||||
|
});
|
||||||
|
await onThumbnailSaved?.();
|
||||||
|
notify("缩略图已保存");
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedOperation(operations: PreviewOperation[]) {
|
function getSelectedOperation(operations: PreviewOperation[]) {
|
||||||
@@ -498,12 +648,12 @@ function getSelectedOperation(operations: PreviewOperation[]) {
|
|||||||
|
|
||||||
function readPreviewBasePoint(): PreviewResolvedBasePoint {
|
function readPreviewBasePoint(): PreviewResolvedBasePoint {
|
||||||
return resolveBasePoint({
|
return resolveBasePoint({
|
||||||
x: readNumberInput("baseX"),
|
x: roundBasePointValue(readNumberInput("baseX")),
|
||||||
y: readNumberInput("baseY"),
|
y: roundBasePointValue(readNumberInput("baseY")),
|
||||||
z: readNumberInput("baseZ"),
|
z: roundBasePointValue(readNumberInput("baseZ")),
|
||||||
rx: readNumberInput("baseRx"),
|
rx: degreesToRadians(readNumberInput("baseRx")),
|
||||||
ry: readNumberInput("baseRy"),
|
ry: degreesToRadians(readNumberInput("baseRy")),
|
||||||
rz: readNumberInput("baseRz")
|
rz: degreesToRadians(readNumberInput("baseRz"))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +663,30 @@ function readNumberInput(name: string) {
|
|||||||
return Number.isFinite(numberValue) ? numberValue : 0;
|
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(
|
function bindImportScene(
|
||||||
model: ModelItem,
|
model: ModelItem,
|
||||||
operations: PreviewOperation[],
|
operations: PreviewOperation[],
|
||||||
@@ -538,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 {
|
function resolveBasePoint(basePoint?: PreviewBasePoint): PreviewResolvedBasePoint {
|
||||||
return {
|
return {
|
||||||
x: basePoint?.x ?? zeroBasePoint.x,
|
x: basePoint?.x ?? zeroBasePoint.x,
|
||||||
@@ -558,7 +740,7 @@ function buildImportScenePayload(options: {
|
|||||||
}): PreviewImportScenePayload {
|
}): PreviewImportScenePayload {
|
||||||
return {
|
return {
|
||||||
model: options.model,
|
model: options.model,
|
||||||
modelUrl: options.model.file_url,
|
modelUrl: resolveStorageUrl(options.model.file_url),
|
||||||
basePointEnabled: options.basePointEnabled,
|
basePointEnabled: options.basePointEnabled,
|
||||||
basePoint: options.basePoint,
|
basePoint: options.basePoint,
|
||||||
rawOperationTree: options.model.operation_tree,
|
rawOperationTree: options.model.operation_tree,
|
||||||
@@ -581,11 +763,16 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
|
|||||||
document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
|
document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
|
||||||
button.closest("li")?.classList.add("is-active");
|
button.closest("li")?.classList.add("is-active");
|
||||||
const index = Number(button.dataset.processIndex ?? 0);
|
const index = Number(button.dataset.processIndex ?? 0);
|
||||||
|
stopPreviewPlayback();
|
||||||
updateStatus(operations[index] ?? null);
|
updateStatus(operations[index] ?? null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
|
document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
|
||||||
|
if (operations.length === 0) {
|
||||||
|
notify("当前模型暂无工艺数据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const operation = getSelectedOperation(operations);
|
const operation = getSelectedOperation(operations);
|
||||||
updateStatus(operation);
|
updateStatus(operation);
|
||||||
if (!operation) {
|
if (!operation) {
|
||||||
@@ -596,19 +783,389 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
|
|||||||
notifyError(`工艺播放数据解析失败:${operation.parseError}`);
|
notifyError(`工艺播放数据解析失败:${operation.parseError}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!operation.parsedCraftPlayData) {
|
if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
|
||||||
notify("当前工艺暂无播放数据");
|
notify("当前工艺暂无播放数据");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
notify(`${operation.CraftName || "当前工艺"} 已读取 ${operation.frameCount} 帧,播放逻辑待接入`);
|
startPreviewPlayback(operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector<HTMLButtonElement>("#previewPauseBtn")?.addEventListener("click", () => {
|
document.querySelector<HTMLButtonElement>("#previewPauseBtn")?.addEventListener("click", () => {
|
||||||
updateStatus(getSelectedOperation(operations));
|
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) {
|
async function initPreview(url: string, context: PreviewLoadContext) {
|
||||||
ensurePreviewContext(context);
|
ensurePreviewContext(context);
|
||||||
const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
|
const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
|
||||||
|
|||||||
96
web/src/pages/app/modules/thumbnail.ts
Normal file
96
web/src/pages/app/modules/thumbnail.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Material, Object3D, PerspectiveCamera, Texture } from "three";
|
||||||
|
import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH = 512;
|
||||||
|
const THUMBNAIL_HEIGHT = 384;
|
||||||
|
|
||||||
|
type ThreeModule = typeof import("three");
|
||||||
|
type LoadedGltf = Awaited<ReturnType<GLTFLoader["loadAsync"]>>;
|
||||||
|
type ParseableGltfLoader = GLTFLoader & {
|
||||||
|
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateModelThumbnailFromFile(file: File) {
|
||||||
|
const [THREE, { GLTFLoader }] = await Promise.all([
|
||||||
|
import("three"),
|
||||||
|
import("three/examples/jsm/loaders/GLTFLoader.js")
|
||||||
|
]);
|
||||||
|
|
||||||
|
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.01, 1000);
|
||||||
|
camera.up.set(0, 0, 1);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
preserveDrawingBuffer: true
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
renderer.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, false);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
const ambientLight = new THREE.HemisphereLight(0xffffff, 0x9aa8b5, 2.2);
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.8);
|
||||||
|
keyLight.position.set(3, -4, 5);
|
||||||
|
scene.add(ambientLight, keyLight);
|
||||||
|
|
||||||
|
let object: Object3D | null = null;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const gltf = await (new GLTFLoader() as ParseableGltfLoader).parseAsync(buffer, "");
|
||||||
|
if (!gltf.scene) {
|
||||||
|
throw new Error("模型文件中未找到可渲染场景");
|
||||||
|
}
|
||||||
|
object = gltf.scene;
|
||||||
|
scene.add(object);
|
||||||
|
fitCameraToObject(THREE, camera, object);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
return renderer.domElement.toDataURL("image/png");
|
||||||
|
} finally {
|
||||||
|
if (object) disposeObject3D(object);
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.forceContextLoss();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, object: Object3D) {
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||||||
|
const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));
|
||||||
|
|
||||||
|
camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
|
||||||
|
camera.near = Math.max(distance / 100, 0.01);
|
||||||
|
camera.far = distance * 100;
|
||||||
|
camera.lookAt(center);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject3D(root: Object3D) {
|
||||||
|
root.traverse((object) => {
|
||||||
|
const maybeMesh = object as Object3D & {
|
||||||
|
geometry?: { dispose?: () => void };
|
||||||
|
material?: Material | Material[];
|
||||||
|
};
|
||||||
|
maybeMesh.geometry?.dispose?.();
|
||||||
|
const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : maybeMesh.material ? [maybeMesh.material] : [];
|
||||||
|
for (const material of materials) {
|
||||||
|
disposeMaterial(material);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeMaterial(material: Material) {
|
||||||
|
const values = Object.values(material as Material & Record<string, unknown>);
|
||||||
|
for (const value of values) {
|
||||||
|
if (value && typeof value === "object" && "isTexture" in value) {
|
||||||
|
(value as Texture).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getToken } from "./authState";
|
import { getToken } from "./authState";
|
||||||
|
import { resolveApiUrl } from "../config/runtime";
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
function authHeaders(): Record<string, string> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -6,10 +7,11 @@ function authHeaders(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const hasBody = options.body !== undefined && options.body !== null;
|
||||||
|
const response = await fetch(resolveApiUrl(url), {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
|
||||||
...authHeaders(),
|
...authHeaders(),
|
||||||
...((options.headers as Record<string, string> | undefined) ?? {})
|
...((options.headers as Record<string, string> | undefined) ?? {})
|
||||||
} as HeadersInit
|
} as HeadersInit
|
||||||
@@ -20,4 +22,3 @@ export async function api<T>(url: string, options: RequestInit = {}): Promise<T>
|
|||||||
}
|
}
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { w2confirm, w2popup, w2utils } from "../vendor/w2ui";
|
import { w2popup, w2utils } from "../vendor/w2ui";
|
||||||
|
|
||||||
type PopupActionEvent = {
|
type PopupActionEvent = {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -23,14 +23,27 @@ export function notifyError(error: unknown) {
|
|||||||
|
|
||||||
export function confirmDialog(message: string, title = "确认") {
|
export function confirmDialog(message: string, title = "确认") {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
w2confirm({
|
let settled = false;
|
||||||
msg: message,
|
const settle = (value: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
w2utils.confirm({
|
||||||
|
box: "body",
|
||||||
title,
|
title,
|
||||||
yes: "确定",
|
text: message,
|
||||||
no: "取消"
|
btn_yes: {
|
||||||
}, undefined, (action: string) => {
|
text: "确定"
|
||||||
resolve(action === "yes" || action === "Yes");
|
},
|
||||||
});
|
btn_no: {
|
||||||
|
text: "取消"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.yes(() => settle(true))
|
||||||
|
.no(() => settle(false))
|
||||||
|
.close(() => settle(false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
web/src/utils/fileDownload.ts
Normal file
30
web/src/utils/fileDownload.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { resolveStorageUrl } from "../config/runtime";
|
||||||
|
|
||||||
|
export async function downloadFileFromUrl(fileUrl: string, fileName: string) {
|
||||||
|
const response = await fetch(resolveStorageUrl(fileUrl), {
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`文件下载失败:${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = ensureGlbFileName(fileName);
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
anchor.style.display = "none";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGlbFileName(fileName: string) {
|
||||||
|
const name = fileName.trim() || "model.glb";
|
||||||
|
return name.toLowerCase().endsWith(".glb") ? name : `${name}.glb`;
|
||||||
|
}
|
||||||
23
web/src/vendor/w2ui.ts
vendored
23
web/src/vendor/w2ui.ts
vendored
@@ -28,6 +28,29 @@ type W2Utils = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
}): unknown;
|
}): 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 = {
|
type W2LayoutOptions = {
|
||||||
|
|||||||
@@ -5,30 +5,36 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const rootDir = path.resolve(__dirname, "../..");
|
const rootDir = path.resolve(__dirname, "../..");
|
||||||
const httpsConfig = {
|
|
||||||
key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")),
|
|
||||||
cert: fs.readFileSync(path.join(rootDir, "certs/localhost+2.pem"))
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineConfig({
|
function getDevHttpsConfig() {
|
||||||
server: {
|
return {
|
||||||
port: 5174,
|
key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")),
|
||||||
strictPort: true,
|
cert: fs.readFileSync(path.join(rootDir, "certs/localhost+2.pem"))
|
||||||
https: httpsConfig,
|
};
|
||||||
headers: {
|
}
|
||||||
// 关键:添加 COEP 和 COOP
|
|
||||||
"Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp"
|
export default defineConfig(({ command }) => ({
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
base: command === "build" ? "/modelLibrary/" : "/",
|
||||||
"Cross-Origin-Resource-Policy": "cross-origin", // 改为 cross-origin
|
server: command === "serve"
|
||||||
// 开发环境允许跨域
|
? {
|
||||||
"Access-Control-Allow-Origin": "https://localhost:3000", // 父页面的地址
|
port: 5174,
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
strictPort: true,
|
||||||
"Access-Control-Allow-Headers": "Content-Type",
|
https: getDevHttpsConfig(),
|
||||||
"Access-Control-Allow-Credentials": "true"
|
headers: {
|
||||||
},
|
// 关键:添加 COEP 和 COOP
|
||||||
proxy: {
|
"Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp"
|
||||||
"/api": "http://127.0.0.1:3001",
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
"/storage": "http://127.0.0.1:3001"
|
"Cross-Origin-Resource-Policy": "cross-origin", // 改为 cross-origin
|
||||||
|
// 开发环境允许跨域
|
||||||
|
"Access-Control-Allow-Origin": "https://localhost:3000", // 父页面的地址
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Access-Control-Allow-Credentials": "true"
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:3001",
|
||||||
|
"/storage": "http://127.0.0.1:3001"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
: undefined
|
||||||
});
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user