Compare commits
13 Commits
c1d5240d2c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ae484b36 | ||
|
|
0673de9ebe | ||
|
|
15d2bc51ca | ||
|
|
0665d7a008 | ||
|
|
025835c5b3 | ||
|
|
a3e7d93872 | ||
|
|
e2e7aa5f06 | ||
|
|
8fb45bd42a | ||
|
|
e215f93ef2 | ||
|
|
bbed65e41e | ||
|
|
d4e78a666e | ||
|
|
74f3e60013 | ||
|
|
91f689c3d4 |
@@ -7,7 +7,7 @@ DB_PATH=
|
||||
# fixed: auto login with FIXED_LOGIN_USERNAME and open model library directly
|
||||
APP_AUTH_MODE=fixed
|
||||
FIXED_LOGIN_USERNAME=admin
|
||||
FIXED_LOGIN_ROLE=user
|
||||
FIXED_LOGIN_ROLE=admin
|
||||
ALLOW_USER_EDIT_SYSTEM_LIBRARY=false
|
||||
|
||||
# local: write to server/storage and serve via /storage
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="%BASE_URL%model-library-config.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -152,8 +152,9 @@
|
||||
.thumb-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 86px));
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
@@ -172,7 +173,7 @@
|
||||
}
|
||||
|
||||
.thumb-actions button {
|
||||
width: min(86px, 80%);
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
|
||||
@@ -64,6 +64,105 @@
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.selected-file-summary {
|
||||
display: block;
|
||||
color: #1f6f8b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selected-file-list {
|
||||
display: block;
|
||||
max-height: 58px;
|
||||
overflow: auto;
|
||||
color: #405469;
|
||||
line-height: 1.45;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #b9d7c1;
|
||||
border-radius: 6px;
|
||||
background: #eef8f1;
|
||||
color: #27613a;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.upload-popup-form.is-uploading .drop-zone {
|
||||
cursor: progress;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.upload-progress-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
}
|
||||
|
||||
.upload-progress-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-progress-panel {
|
||||
width: min(460px, 100%);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid #dbe5ec;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.upload-progress-title {
|
||||
color: #1c2733;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upload-progress-message {
|
||||
color: #405469;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.upload-progress-track {
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e8eef3;
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #1f6f8b;
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.upload-progress-meta {
|
||||
color: #27613a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upload-progress-file {
|
||||
max-height: 48px;
|
||||
overflow: auto;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.upload-target-path {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function renderApp() {
|
||||
const currentUser = getCurrentUser();
|
||||
const isAdmin = currentUser?.role === "admin";
|
||||
const isFixedLogin = document.body.dataset.authMode === "fixed";
|
||||
const canManageUsers = isAdmin && !isFixedLogin;
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
@@ -32,10 +33,10 @@ export async function renderApp() {
|
||||
<main class="content-pane">
|
||||
<div class="content-toolbar">
|
||||
<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="addProcessModelBtn" type="button" hidden>上传工艺模型</button>` : ""}
|
||||
${isAdmin ? `<button id="addModelBtn1" class="primary-btn" type="button" hidden>增加模型</button>` : ""}
|
||||
${canManageUsers ? `<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>` : ""}
|
||||
${canManageUsers ? `<button id="addModelBtn1" class="primary-btn" type="button" hidden>增加模型</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-toolbar">
|
||||
@@ -55,8 +56,8 @@ export async function renderApp() {
|
||||
<span>关键字</span>
|
||||
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
||||
</label>
|
||||
<button id="resetFilterBtn" type="button">重置</button>
|
||||
<button id="addModelBtn" class="primary-btn" type="button">增加模型</button>
|
||||
<button id="resetFilterBtn" type="button">筛选模型</button>
|
||||
<button id="addModelBtn" class="primary-btn" type="button">导入模型</button>
|
||||
</div>
|
||||
<div id="modelGrid" class="model-grid"></div>
|
||||
<footer id="modelPagination" class="pagination"></footer>
|
||||
|
||||
@@ -5,10 +5,14 @@ import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../
|
||||
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
||||
import { downloadFileFromUrl } from "../../../utils/fileDownload";
|
||||
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
||||
import { resolveStorageUrl } from "../../../config/runtime";
|
||||
import { appState } from "../appState";
|
||||
import { generateModelThumbnailFromFile } from "./thumbnail";
|
||||
|
||||
type UploadFormState = {
|
||||
file: File | null;
|
||||
files: File[];
|
||||
thumbnailFailures: string[];
|
||||
};
|
||||
|
||||
type UploadModelPayload = {
|
||||
@@ -22,6 +26,10 @@ type UploadModelPayload = {
|
||||
properties?: Record<string, string>;
|
||||
};
|
||||
|
||||
type UploadModelResponse = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
|
||||
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
|
||||
@@ -287,7 +295,7 @@ function renderModelCard(item: ModelItem) {
|
||||
const canWrite = item.permissions.write;
|
||||
const prop = item.properties ?? {};
|
||||
const thumb = item.thumbnail_url
|
||||
? `<img src="${item.thumbnail_url}" alt="" />`
|
||||
? `<img src="${resolveStorageUrl(item.thumbnail_url)}" alt="" />`
|
||||
: `<div class="thumb-placeholder">暂无预览图</div>`;
|
||||
return `
|
||||
<article class="model-card" data-model-id="${item.id}">
|
||||
@@ -308,8 +316,6 @@ function renderModelCard(item: ModelItem) {
|
||||
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
|
||||
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
|
||||
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
|
||||
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
|
||||
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
`;
|
||||
@@ -470,10 +476,11 @@ export async function uploadModelToBackend(payload: UploadModelPayload) {
|
||||
form.set(`prop.${key}`, value);
|
||||
}
|
||||
|
||||
await api("/api/models/upload", {
|
||||
const result = await api<UploadModelResponse>("/api/models/upload", {
|
||||
method: "POST",
|
||||
body: form
|
||||
});
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function openUploadModelDialog() {
|
||||
@@ -482,21 +489,21 @@ async function openUploadModelDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
const state: UploadFormState = { file: null };
|
||||
const state: UploadFormState = { file: null, files: [], thumbnailFailures: [] };
|
||||
await loadDictionaries();
|
||||
const result = await formDialog<boolean>({
|
||||
title: "增加模型",
|
||||
width: 560,
|
||||
height: 520,
|
||||
height: 430,
|
||||
blockPage: false,
|
||||
body: `
|
||||
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
|
||||
<label>
|
||||
<span>模型文件</span>
|
||||
<div id="modelDropZone" class="drop-zone">
|
||||
<input id="modelUploadFile" type="file" accept=".glb" />
|
||||
<input id="modelUploadFile" type="file" accept=".glb" multiple />
|
||||
<strong>选择模型</strong>
|
||||
<em>或拖拽 .glb 模型到这里</em>
|
||||
<em>或拖拽一个或多个 .glb 模型到这里</em>
|
||||
<small id="selectedFileName">未选择文件</small>
|
||||
</div>
|
||||
</label>
|
||||
@@ -508,40 +515,93 @@ async function openUploadModelDialog() {
|
||||
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
||||
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
||||
<label><span>型号</span><input name="model" /></label>
|
||||
<label><span>价钱</span><input name="price" /></label>
|
||||
<label><span>重量</span><input name="weight" /></label>
|
||||
</div>
|
||||
<div id="modelUploadStatus" class="upload-status" aria-live="polite" hidden></div>
|
||||
${dictionaryDatalistHtml()}
|
||||
</form>
|
||||
`,
|
||||
onOpen: () => bindUploadDialogEvents(state),
|
||||
onSubmit: async () => {
|
||||
if (!state.file) {
|
||||
if (state.files.length === 0) {
|
||||
throw new Error("请选择 .glb 模型文件");
|
||||
}
|
||||
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
||||
if (!name) {
|
||||
if (state.files.length === 1 && !name) {
|
||||
throw new Error("模型名称不能为空");
|
||||
}
|
||||
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
||||
await uploadModelToBackend({
|
||||
file: state.file,
|
||||
name,
|
||||
fileName: state.file.name,
|
||||
brandName: String(form.get("brandName") ?? ""),
|
||||
typeName: String(form.get("typeName") ?? ""),
|
||||
operationTree: defaultOperationTree,
|
||||
properties: {
|
||||
model: String(form.get("model") ?? ""),
|
||||
price: String(form.get("price") ?? ""),
|
||||
weight: String(form.get("weight") ?? "")
|
||||
const brandName = String(form.get("brandName") ?? "");
|
||||
const typeName = String(form.get("typeName") ?? "");
|
||||
const properties = {
|
||||
model: String(form.get("model") ?? "")
|
||||
};
|
||||
let completedCount = 0;
|
||||
const totalCount = state.files.length;
|
||||
setUploadDialogBusy(state, true);
|
||||
showUploadProgressOverlay(totalCount);
|
||||
try {
|
||||
for (const [index, file] of state.files.entries()) {
|
||||
const uploadingMessage = `正在上传 ${index + 1}/${totalCount}:${file.name}`;
|
||||
updateUploadStatus(uploadingMessage);
|
||||
updateUploadProgressOverlay({
|
||||
total: totalCount,
|
||||
completed: completedCount,
|
||||
message: uploadingMessage,
|
||||
fileName: file.name
|
||||
});
|
||||
await waitForUploadStatusPaint();
|
||||
const modelId = await uploadModelToBackend({
|
||||
file,
|
||||
name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name),
|
||||
fileName: file.name,
|
||||
brandName,
|
||||
typeName,
|
||||
operationTree: defaultOperationTree,
|
||||
properties
|
||||
});
|
||||
const thumbnailMessage = `正在生成预览图 ${index + 1}/${totalCount}:${file.name}`;
|
||||
updateUploadStatus(thumbnailMessage);
|
||||
updateUploadProgressOverlay({
|
||||
total: totalCount,
|
||||
completed: completedCount,
|
||||
message: thumbnailMessage,
|
||||
fileName: file.name
|
||||
});
|
||||
await waitForUploadStatusPaint();
|
||||
try {
|
||||
await generateAndSaveModelThumbnail(modelId, file);
|
||||
} catch {
|
||||
state.thumbnailFailures.push(file.name);
|
||||
}
|
||||
completedCount += 1;
|
||||
const completedMessage = `已完成 ${completedCount}/${totalCount}`;
|
||||
updateUploadStatus(completedMessage);
|
||||
updateUploadProgressOverlay({
|
||||
total: totalCount,
|
||||
completed: completedCount,
|
||||
message: completedMessage,
|
||||
fileName: file.name
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
updateUploadStatus(`上传失败,已完成 ${completedCount}/${totalCount},请处理后重试。`);
|
||||
throw error;
|
||||
} finally {
|
||||
hideUploadProgressOverlay();
|
||||
if (completedCount < state.files.length) {
|
||||
setUploadDialogBusy(state, false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (state.thumbnailFailures.length > 0) {
|
||||
notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`);
|
||||
} else {
|
||||
notify("模型上传和预览图生成完成");
|
||||
}
|
||||
invalidateDictionaries();
|
||||
await reloadFoldersAndModels();
|
||||
}
|
||||
@@ -557,7 +617,7 @@ export async function openProcessModelUploadDialog(input?: {
|
||||
return;
|
||||
}
|
||||
|
||||
const state: UploadFormState = { file: input?.file ?? null };
|
||||
const state: UploadFormState = { file: input?.file ?? null, files: input?.file ? [input.file] : [], thumbnailFailures: [] };
|
||||
await loadDictionaries();
|
||||
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
|
||||
const result = await formDialog<boolean>({
|
||||
@@ -572,8 +632,6 @@ export async function openProcessModelUploadDialog(input?: {
|
||||
<label><span>品牌</span><input name="brandName" list="brandOptions" autocomplete="off" /></label>
|
||||
<label><span>类型</span><input name="typeName" list="typeOptions" autocomplete="off" /></label>
|
||||
<label><span>型号</span><input name="model" autocomplete="off" /></label>
|
||||
<label><span>价钱</span><input name="price" autocomplete="off" /></label>
|
||||
<label><span>重量</span><input name="weight" autocomplete="off" /></label>
|
||||
</div>
|
||||
${dictionaryDatalistHtml()}
|
||||
</form>
|
||||
@@ -585,7 +643,7 @@ export async function openProcessModelUploadDialog(input?: {
|
||||
}
|
||||
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
await uploadModelToBackend({
|
||||
const modelId = await uploadModelToBackend({
|
||||
file: state.file,
|
||||
name,
|
||||
fileName: state.file.name,
|
||||
@@ -593,11 +651,14 @@ export async function openProcessModelUploadDialog(input?: {
|
||||
typeName: String(form.get("typeName") ?? ""),
|
||||
operationTree: String(form.get("operationTree") ?? ""),
|
||||
properties: {
|
||||
model: String(form.get("model") ?? ""),
|
||||
price: String(form.get("price") ?? ""),
|
||||
weight: String(form.get("weight") ?? "")
|
||||
model: String(form.get("model") ?? "")
|
||||
}
|
||||
});
|
||||
try {
|
||||
await generateAndSaveModelThumbnail(modelId, state.file);
|
||||
} catch {
|
||||
notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -614,21 +675,48 @@ function bindUploadDialogEvents(state: UploadFormState) {
|
||||
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
||||
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
|
||||
|
||||
const selectFile = (file: File) => {
|
||||
if (!file.name.toLowerCase().endsWith(".glb")) {
|
||||
const renderSelectedFiles = () => {
|
||||
if (state.files.length === 0) {
|
||||
selectedFileName.textContent = "未选择文件";
|
||||
nameInput.disabled = false;
|
||||
nameInput.value = "";
|
||||
nameInput.placeholder = "选择文件后自动填入";
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.files.length === 1) {
|
||||
const [file] = state.files;
|
||||
selectedFileName.textContent = file.name;
|
||||
nameInput.disabled = false;
|
||||
nameInput.value = modelNameFromFile(file.name);
|
||||
nameInput.placeholder = "选择文件后自动填入";
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFileName.innerHTML = `
|
||||
<span class="selected-file-summary">已选择 ${state.files.length} 个模型,名称按文件名自动生成</span>
|
||||
<span class="selected-file-list">${state.files.map((file) => escapeHtml(file.name)).join("<br>")}</span>
|
||||
`;
|
||||
nameInput.disabled = true;
|
||||
nameInput.value = `${state.files.length} 个模型按文件名自动命名`;
|
||||
nameInput.placeholder = "";
|
||||
};
|
||||
|
||||
const selectFiles = (files: FileList | File[]) => {
|
||||
const selectedFiles = Array.from(files);
|
||||
if (selectedFiles.length === 0) return;
|
||||
const invalidFiles = selectedFiles.filter((file) => !file.name.toLowerCase().endsWith(".glb"));
|
||||
if (invalidFiles.length > 0) {
|
||||
notify("当前阶段只允许上传 .glb 模型");
|
||||
return;
|
||||
}
|
||||
state.file = file;
|
||||
selectedFileName.textContent = file.name;
|
||||
if (!nameInput.value.trim()) {
|
||||
nameInput.value = modelNameFromFile(file.name);
|
||||
}
|
||||
state.files = selectedFiles;
|
||||
state.file = selectedFiles[0] ?? null;
|
||||
renderSelectedFiles();
|
||||
};
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) selectFile(file);
|
||||
if (fileInput.files) selectFiles(fileInput.files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener("click", (event) => {
|
||||
@@ -645,8 +733,90 @@ function bindUploadDialogEvents(state: UploadFormState) {
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragover");
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) selectFile(file);
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) selectFiles(files);
|
||||
});
|
||||
}
|
||||
|
||||
function updateUploadStatus(message: string) {
|
||||
const status = document.querySelector<HTMLElement>("#modelUploadStatus");
|
||||
if (!status) return;
|
||||
status.hidden = false;
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
function waitForUploadStatusPaint() {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function showUploadProgressOverlay(total: number) {
|
||||
let overlay = document.querySelector<HTMLElement>("#modelUploadProgressOverlay");
|
||||
if (!overlay) {
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "modelUploadProgressOverlay";
|
||||
overlay.className = "upload-progress-overlay";
|
||||
overlay.setAttribute("role", "status");
|
||||
overlay.setAttribute("aria-live", "polite");
|
||||
overlay.innerHTML = `
|
||||
<div class="upload-progress-panel">
|
||||
<div class="upload-progress-title">模型上传中</div>
|
||||
<div id="modelUploadProgressMessage" class="upload-progress-message">正在准备上传...</div>
|
||||
<div class="upload-progress-track">
|
||||
<div id="modelUploadProgressBar" class="upload-progress-bar"></div>
|
||||
</div>
|
||||
<div id="modelUploadProgressMeta" class="upload-progress-meta">0/${total}</div>
|
||||
<div id="modelUploadProgressFile" class="upload-progress-file"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.hidden = false;
|
||||
updateUploadProgressOverlay({
|
||||
total,
|
||||
completed: 0,
|
||||
message: "正在准备上传...",
|
||||
fileName: ""
|
||||
});
|
||||
}
|
||||
|
||||
function updateUploadProgressOverlay(options: {
|
||||
total: number;
|
||||
completed: number;
|
||||
message: string;
|
||||
fileName: string;
|
||||
}) {
|
||||
const message = document.querySelector<HTMLElement>("#modelUploadProgressMessage");
|
||||
const meta = document.querySelector<HTMLElement>("#modelUploadProgressMeta");
|
||||
const file = document.querySelector<HTMLElement>("#modelUploadProgressFile");
|
||||
const bar = document.querySelector<HTMLElement>("#modelUploadProgressBar");
|
||||
const percent = options.total > 0 ? Math.round((options.completed / options.total) * 100) : 0;
|
||||
if (message) message.textContent = options.message;
|
||||
if (meta) meta.textContent = `${options.completed}/${options.total}`;
|
||||
if (file) file.textContent = options.fileName;
|
||||
if (bar) bar.style.width = `${percent}%`;
|
||||
}
|
||||
|
||||
function hideUploadProgressOverlay() {
|
||||
const overlay = document.querySelector<HTMLElement>("#modelUploadProgressOverlay");
|
||||
if (overlay) overlay.hidden = true;
|
||||
}
|
||||
|
||||
async function generateAndSaveModelThumbnail(modelId: number, file: File) {
|
||||
const thumbnail = await generateModelThumbnailFromFile(file);
|
||||
await api(`/api/models/${modelId}/thumbnail`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ thumbnail })
|
||||
});
|
||||
}
|
||||
|
||||
function setUploadDialogBusy(state: UploadFormState, busy: boolean) {
|
||||
const form = document.querySelector<HTMLFormElement>("#modelUploadPopupForm");
|
||||
if (!form) return;
|
||||
form.classList.toggle("is-uploading", busy);
|
||||
form.querySelectorAll<HTMLInputElement>("input").forEach((input) => {
|
||||
input.disabled = busy || (input.id === "modelUploadName" && state.files.length > 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -750,7 +920,7 @@ async function editModel(id: number) {
|
||||
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
|
||||
const oldName = card?.querySelector("strong")?.textContent ?? "";
|
||||
await loadDictionaries();
|
||||
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string; price: string; weight: string }>({
|
||||
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string }>({
|
||||
title: "编辑模型",
|
||||
width: 520,
|
||||
height: 390,
|
||||
@@ -762,8 +932,6 @@ async function editModel(id: number) {
|
||||
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>价钱</span><input name="price" value="${escapeHtml(card?.querySelector("dl div:nth-child(4) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>重量</span><input name="weight" value="${escapeHtml(card?.querySelector("dl div:nth-child(5) dd")?.textContent ?? "")}" /></label>
|
||||
</div>
|
||||
${dictionaryDatalistHtml()}
|
||||
</form>
|
||||
@@ -778,9 +946,7 @@ async function editModel(id: number) {
|
||||
name,
|
||||
brandName: String(form.get("brandName") ?? ""),
|
||||
typeName: String(form.get("typeName") ?? ""),
|
||||
model: String(form.get("model") ?? ""),
|
||||
price: String(form.get("price") ?? ""),
|
||||
weight: String(form.get("weight") ?? "")
|
||||
model: String(form.get("model") ?? "")
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -792,9 +958,7 @@ async function editModel(id: number) {
|
||||
brandName: result.brandName,
|
||||
typeName: result.typeName,
|
||||
properties: {
|
||||
model: result.model,
|
||||
price: result.price,
|
||||
weight: result.weight
|
||||
model: result.model
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { downloadFileFromUrl } from "../../../utils/fileDownload";
|
||||
import { escapeHtml, formatBytes } from "../../../utils/format";
|
||||
import { notify, notifyError } from "../../../ui/dialogs";
|
||||
import { api } from "../../../services/api";
|
||||
import { resolveStorageUrl } from "../../../config/runtime";
|
||||
|
||||
type PreviewRuntime = {
|
||||
renderer: WebGLRenderer;
|
||||
@@ -146,7 +147,7 @@ export function openModelPreview(
|
||||
) {
|
||||
const context = beginPreviewContext();
|
||||
const canWrite = model.permissions.write;
|
||||
const url = model.file_url;
|
||||
const url = resolveStorageUrl(model.file_url);
|
||||
const operations = parseModelOperations(model.operation_tree);
|
||||
const popup = w2popup.open({
|
||||
title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`,
|
||||
@@ -300,7 +301,7 @@ function buildParentImportScenePayload(payload: PreviewImportScenePayload): Pare
|
||||
id: payload.model.id,
|
||||
name: payload.model.name,
|
||||
original_filename: payload.model.original_filename,
|
||||
file_url: payload.model.file_url
|
||||
file_url: resolveStorageUrl(payload.model.file_url)
|
||||
},
|
||||
modelUrl: payload.modelUrl,
|
||||
basePointEnabled: payload.basePointEnabled,
|
||||
@@ -739,7 +740,7 @@ function buildImportScenePayload(options: {
|
||||
}): PreviewImportScenePayload {
|
||||
return {
|
||||
model: options.model,
|
||||
modelUrl: options.model.file_url,
|
||||
modelUrl: resolveStorageUrl(options.model.file_url),
|
||||
basePointEnabled: options.basePointEnabled,
|
||||
basePoint: options.basePoint,
|
||||
rawOperationTree: options.model.operation_tree,
|
||||
|
||||
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 { resolveApiUrl } from "../config/runtime";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
@@ -7,7 +8,7 @@ function authHeaders(): Record<string, string> {
|
||||
|
||||
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const hasBody = options.body !== undefined && options.body !== null;
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(resolveApiUrl(url), {
|
||||
...options,
|
||||
headers: {
|
||||
...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { resolveStorageUrl } from "../config/runtime";
|
||||
|
||||
export async function downloadFileFromUrl(fileUrl: string, fileName: string) {
|
||||
const response = await fetch(fileUrl, {
|
||||
const response = await fetch(resolveStorageUrl(fileUrl), {
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -5,30 +5,36 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
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({
|
||||
server: {
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
https: httpsConfig,
|
||||
headers: {
|
||||
// 关键:添加 COEP 和 COOP
|
||||
"Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp"
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "cross-origin", // 改为 cross-origin
|
||||
// 开发环境允许跨域
|
||||
"Access-Control-Allow-Origin": "https://localhost:3000", // 父页面的地址
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Credentials": "true"
|
||||
},
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:3001",
|
||||
"/storage": "http://127.0.0.1:3001"
|
||||
function getDevHttpsConfig() {
|
||||
return {
|
||||
key: fs.readFileSync(path.join(rootDir, "certs/localhost+2-key.pem")),
|
||||
cert: fs.readFileSync(path.join(rootDir, "certs/localhost+2.pem"))
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: command === "build" ? "/modelLibrary/" : "/",
|
||||
server: command === "serve"
|
||||
? {
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
https: getDevHttpsConfig(),
|
||||
headers: {
|
||||
// 关键:添加 COEP 和 COOP
|
||||
"Cross-Origin-Embedder-Policy": "credentialless", // 或 "require-corp"
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "cross-origin", // 改为 cross-origin
|
||||
// 开发环境允许跨域
|
||||
"Access-Control-Allow-Origin": "https://localhost:3000", // 父页面的地址
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Credentials": "true"
|
||||
},
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:3001",
|
||||
"/storage": "http://127.0.0.1:3001"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
: undefined
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user