Compare commits

...

10 Commits

Author SHA1 Message Date
zhangshun
91ae484b36 修改模型树相关配置 2026-07-11 13:41:11 +08:00
zhangshun
0673de9ebe 修正模型库子路径构建配置 2026-07-10 13:34:03 +08:00
zhangshun
15d2bc51ca 上传模型时自动生成预览图 2026-07-08 18:38:48 +08:00
zhangshun
0665d7a008 上传模型时显示全屏进度 2026-07-08 18:27:51 +08:00
zhangshun
025835c5b3 优化批量上传模型提示 2026-07-08 18:18:11 +08:00
zhangshun
a3e7d93872 支持批量导入模型 2026-07-08 18:03:24 +08:00
zhangshun
e2e7aa5f06 增加前端运行时后端地址配置 2026-06-02 13:32:08 +08:00
zhangshun
8fb45bd42a 生产构建跳过本地开发证书 2026-06-02 11:46:55 +08:00
zhangshun
e215f93ef2 修改部分按钮显示 2026-06-02 10:38:40 +08:00
zhangshun
bbed65e41e 调整模型操作按钮布局 2026-06-02 10:33:37 +08:00
12 changed files with 516 additions and 68 deletions

View File

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

View File

@@ -0,0 +1,9 @@
window.__DMT_MODEL_LIBRARY_CONFIG__ = {
// 后端服务根地址。为空时使用当前页面域名,例如 /api/models。
// 示例:"https://model-api.example.com"
API_BASE_URL: "https://dmt.meswork.com/modelLibrary",
// 模型文件和缩略图服务根地址。为空时优先复用 API_BASE_URL。
// 示例:"https://model-api.example.com"
STORAGE_BASE_URL: "https://dmt.meswork.com"
};

52
web/src/config/runtime.ts Normal file
View File

@@ -0,0 +1,52 @@
type RuntimeConfig = {
API_BASE_URL?: string;
STORAGE_BASE_URL?: string;
};
declare global {
interface Window {
__DMT_MODEL_LIBRARY_CONFIG__?: RuntimeConfig;
}
}
function runtimeConfig() {
return window.__DMT_MODEL_LIBRARY_CONFIG__ ?? {};
}
function cleanBaseUrl(value?: string) {
return value?.trim().replace(/\/+$/g, "") ?? "";
}
function isAbsoluteUrl(url: string) {
return /^https?:\/\//i.test(url);
}
function joinUrl(baseUrl: string, path: string) {
if (!baseUrl || isAbsoluteUrl(path)) return path;
return `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
}
function joinApiUrl(baseUrl: string, path: string) {
if (!baseUrl || isAbsoluteUrl(path)) return path;
try {
const base = new URL(baseUrl);
if (base.pathname.replace(/\/+$/g, "") === "/api" && path.startsWith("/api/")) {
return joinUrl(baseUrl, path.slice(4));
}
} catch {
// 非完整 URL 时按普通路径拼接。
}
return joinUrl(baseUrl, path);
}
export function resolveApiUrl(path: string) {
return joinApiUrl(cleanBaseUrl(runtimeConfig().API_BASE_URL), path);
}
export function resolveStorageUrl(path: string) {
const storageBaseUrl = cleanBaseUrl(runtimeConfig().STORAGE_BASE_URL);
const apiBaseUrl = cleanBaseUrl(runtimeConfig().API_BASE_URL);
return joinUrl(storageBaseUrl || apiBaseUrl, path);
}

View File

@@ -152,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;
@@ -172,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);

View File

@@ -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);

View File

@@ -35,8 +35,8 @@ export async function renderApp() {
<div class="content-actions"> <div class="content-actions">
${canManageUsers ? `<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">
@@ -56,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>

View File

@@ -5,10 +5,14 @@ import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs"; import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
import { downloadFileFromUrl } from "../../../utils/fileDownload"; 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 = {
@@ -22,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";
@@ -287,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}">
@@ -468,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() {
@@ -480,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>
@@ -507,35 +516,92 @@ async function openUploadModelDialog() {
<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>
</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,
brandName: String(form.get("brandName") ?? ""),
typeName: String(form.get("typeName") ?? ""),
operationTree: defaultOperationTree,
properties: {
model: String(form.get("model") ?? "") model: String(form.get("model") ?? "")
} };
let completedCount = 0;
const totalCount = state.files.length;
setUploadDialogBusy(state, true);
showUploadProgressOverlay(totalCount);
try {
for (const [index, file] of state.files.entries()) {
const uploadingMessage = `正在上传 ${index + 1}/${totalCount}${file.name}`;
updateUploadStatus(uploadingMessage);
updateUploadProgressOverlay({
total: totalCount,
completed: completedCount,
message: uploadingMessage,
fileName: file.name
}); });
await waitForUploadStatusPaint();
const modelId = await uploadModelToBackend({
file,
name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name),
fileName: file.name,
brandName,
typeName,
operationTree: defaultOperationTree,
properties
});
const thumbnailMessage = `正在生成预览图 ${index + 1}/${totalCount}${file.name}`;
updateUploadStatus(thumbnailMessage);
updateUploadProgressOverlay({
total: totalCount,
completed: completedCount,
message: thumbnailMessage,
fileName: file.name
});
await waitForUploadStatusPaint();
try {
await generateAndSaveModelThumbnail(modelId, file);
} catch {
state.thumbnailFailures.push(file.name);
}
completedCount += 1;
const completedMessage = `已完成 ${completedCount}/${totalCount}`;
updateUploadStatus(completedMessage);
updateUploadProgressOverlay({
total: totalCount,
completed: completedCount,
message: completedMessage,
fileName: file.name
});
}
} catch (error) {
updateUploadStatus(`上传失败,已完成 ${completedCount}/${totalCount},请处理后重试。`);
throw error;
} finally {
hideUploadProgressOverlay();
if (completedCount < state.files.length) {
setUploadDialogBusy(state, false);
}
}
return true; return true;
} }
}); });
if (result) { if (result) {
if (state.thumbnailFailures.length > 0) {
notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`);
} else {
notify("模型上传和预览图生成完成");
}
invalidateDictionaries(); invalidateDictionaries();
await reloadFoldersAndModels(); await reloadFoldersAndModels();
} }
@@ -551,7 +617,7 @@ 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>({
@@ -577,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,
@@ -588,6 +654,11 @@ export async function openProcessModelUploadDialog(input?: {
model: String(form.get("model") ?? "") model: String(form.get("model") ?? "")
} }
}); });
try {
await generateAndSaveModelThumbnail(modelId, state.file);
} catch {
notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。");
}
return true; return true;
} }
}); });
@@ -604,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) => {
@@ -635,8 +733,90 @@ 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);
}); });
} }

View File

@@ -9,6 +9,7 @@ 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;
@@ -146,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)}`,
@@ -300,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,
@@ -739,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,

View File

@@ -0,0 +1,96 @@
import type { Material, Object3D, PerspectiveCamera, Texture } from "three";
import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const THUMBNAIL_WIDTH = 512;
const THUMBNAIL_HEIGHT = 384;
type ThreeModule = typeof import("three");
type LoadedGltf = Awaited<ReturnType<GLTFLoader["loadAsync"]>>;
type ParseableGltfLoader = GLTFLoader & {
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
};
export async function generateModelThumbnailFromFile(file: File) {
const [THREE, { GLTFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/GLTFLoader.js")
]);
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.01, 1000);
camera.up.set(0, 0, 1);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true
});
renderer.setPixelRatio(1);
renderer.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, false);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const ambientLight = new THREE.HemisphereLight(0xffffff, 0x9aa8b5, 2.2);
const keyLight = new THREE.DirectionalLight(0xffffff, 2.8);
keyLight.position.set(3, -4, 5);
scene.add(ambientLight, keyLight);
let object: Object3D | null = null;
try {
const buffer = await file.arrayBuffer();
const gltf = await (new GLTFLoader() as ParseableGltfLoader).parseAsync(buffer, "");
if (!gltf.scene) {
throw new Error("模型文件中未找到可渲染场景");
}
object = gltf.scene;
scene.add(object);
fitCameraToObject(THREE, camera, object);
renderer.render(scene, camera);
return renderer.domElement.toDataURL("image/png");
} finally {
if (object) disposeObject3D(object);
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
}
function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, object: Object3D) {
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxSize = Math.max(size.x, size.y, size.z) || 1;
const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));
camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
camera.near = Math.max(distance / 100, 0.01);
camera.far = distance * 100;
camera.lookAt(center);
camera.updateProjectionMatrix();
}
function disposeObject3D(root: Object3D) {
root.traverse((object) => {
const maybeMesh = object as Object3D & {
geometry?: { dispose?: () => void };
material?: Material | Material[];
};
maybeMesh.geometry?.dispose?.();
const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : maybeMesh.material ? [maybeMesh.material] : [];
for (const material of materials) {
disposeMaterial(material);
}
});
}
function disposeMaterial(material: Material) {
const values = Object.values(material as Material & Record<string, unknown>);
for (const value of values) {
if (value && typeof value === "object" && "isTexture" in value) {
(value as Texture).dispose();
}
}
material.dispose();
}

View File

@@ -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();
@@ -7,7 +8,7 @@ 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 hasBody = options.body !== undefined && options.body !== null; const hasBody = options.body !== undefined && options.body !== null;
const response = await fetch(url, { const response = await fetch(resolveApiUrl(url), {
...options, ...options,
headers: { headers: {
...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}), ...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),

View File

@@ -1,5 +1,7 @@
import { resolveStorageUrl } from "../config/runtime";
export async function downloadFileFromUrl(fileUrl: string, fileName: string) { export async function downloadFileFromUrl(fileUrl: string, fileName: string) {
const response = await fetch(fileUrl, { const response = await fetch(resolveStorageUrl(fileUrl), {
credentials: "include" credentials: "include"
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -5,16 +5,21 @@ 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 = {
function getDevHttpsConfig() {
return {
key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")), key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")),
cert: fs.readFileSync(path.join(rootDir, "certs/localhost+2.pem")) cert: fs.readFileSync(path.join(rootDir, "certs/localhost+2.pem"))
}; };
}
export default defineConfig({ export default defineConfig(({ command }) => ({
server: { base: command === "build" ? "/modelLibrary/" : "/",
server: command === "serve"
? {
port: 5174, port: 5174,
strictPort: true, strictPort: true,
https: httpsConfig, https: getDevHttpsConfig(),
headers: { headers: {
// 关键:添加 COEP 和 COOP // 关键:添加 COEP 和 COOP
"Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp" "Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp"
@@ -31,4 +36,5 @@ export default defineConfig({
"/storage": "http://127.0.0.1:3001" "/storage": "http://127.0.0.1:3001"
} }
} }
}); : undefined
}));