Files
dmt-modelLibrary/web/src/pages/app/modules/models.ts
2026-07-08 18:27:51 +08:00

948 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from "../../../services/api";
import { renderPagination } from "../../../components/pagination";
import { getCurrentUser } from "../../../services/authState";
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
import { downloadFileFromUrl } from "../../../utils/fileDownload";
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
import { resolveStorageUrl } from "../../../config/runtime";
import { appState } from "../appState";
type UploadFormState = {
file: File | null;
files: File[];
};
type UploadModelPayload = {
file: File;
name: string;
fileName?: string;
folderId?: number;
brandName?: string;
typeName?: string;
operationTree?: string;
properties?: Record<string, string>;
};
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
const sceneModelErrorMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL_ERROR";
type SceneModelMessagePayload = {
modelName?: string;
fileName?: string;
modelBuffer?: ArrayBuffer;
operationTreeJson?: string;
};
type SceneModelMessage = {
type?: string;
requestId?: string;
payload?: SceneModelMessagePayload;
message?: string;
};
let dictionariesLoaded = false;
let dictionariesLoading: Promise<void> | null = null;
let sceneModelBridgeBound = false;
let pendingSceneModelRequestId = "";
let pendingSceneModelRequestTimer: number | null = null;
let visibleModels = new Map<number, ModelItem>();
export function bindModelActions() {
const isAdmin = getCurrentUser()?.role === "admin";
bindSceneModelBridge();
ensureImportSceneModelButton();
if (isAdmin) {
document.querySelector("#manageUsersBtn")?.addEventListener("click", async () => {
try {
const { openUserManager } = await import("./users");
await openUserManager();
} catch (error) {
notifyError(error);
}
});
document.querySelector("#manageDictionariesBtn")?.addEventListener("click", async () => {
try {
const { openDictionaryManager } = await import("./dictionaries");
await openDictionaryManager(async () => {
invalidateDictionaries();
await loadModels();
});
} catch (error) {
notifyError(error);
}
});
}
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
try {
await openUploadModelDialog();
} catch (error) {
notifyError(error);
}
});
document.querySelector("#addProcessModelBtn")?.addEventListener("click", async () => {
try {
await openProcessModelUploadDialog();
} catch (error) {
notifyError(error);
}
});
document.querySelector<HTMLSelectElement>("#brandFilter")!.addEventListener("change", async (event) => {
appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value;
appState.page = 1;
await loadModels();
});
document.querySelector<HTMLSelectElement>("#typeFilter")!.addEventListener("change", async (event) => {
appState.filters.typeId = (event.currentTarget as HTMLSelectElement).value;
appState.page = 1;
await loadModels();
});
document.querySelector<HTMLInputElement>("#keywordFilter")!.addEventListener("keydown", async (event) => {
if (event.key !== "Enter") return;
appState.filters.keyword = (event.currentTarget as HTMLInputElement).value.trim();
appState.page = 1;
await loadModels();
});
document.querySelector<HTMLInputElement>("#keywordFilter")!.addEventListener("change", async (event) => {
appState.filters.keyword = (event.currentTarget as HTMLInputElement).value.trim();
appState.page = 1;
await loadModels();
});
document.querySelector("#resetFilterBtn")!.addEventListener("click", async () => {
appState.filters.brandId = "";
appState.filters.typeId = "";
appState.filters.keyword = "";
document.querySelector<HTMLSelectElement>("#brandFilter")!.value = "";
document.querySelector<HTMLSelectElement>("#typeFilter")!.value = "";
document.querySelector<HTMLInputElement>("#keywordFilter")!.value = "";
appState.page = 1;
await loadModels();
});
}
export async function loadModels(options: { preserveCards?: boolean } = {}) {
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
if (!grid || !appState.selectedFolderId) return;
await loadDictionaries();
syncSelectedFolderActions();
document.querySelector("#folderCrumb")!.textContent = appState.selectedFolderName || "模型库";
const params = new URLSearchParams({
folderId: String(appState.selectedFolderId),
page: String(appState.page),
pageSize: String(appState.pageSize)
});
if (appState.filters.brandId) params.set("brandId", appState.filters.brandId);
if (appState.filters.typeId) params.set("typeId", appState.filters.typeId);
if (appState.filters.keyword) params.set("keyword", appState.filters.keyword);
const result = await api<ModelListResponse>(`/api/models?${params.toString()}`);
if (result.items.length === 0 && appState.page > 1) {
appState.page -= 1;
return loadModels();
}
renderPagination({
container: document.querySelector<HTMLElement>("#modelPagination")!,
total: result.total,
page: appState.page,
pageSize: appState.pageSize,
onChange: async (page, pageSize) => {
appState.page = page;
appState.pageSize = pageSize;
await loadModels();
}
});
visibleModels = new Map(result.items.map((item) => [item.id, item]));
renderModelGrid(grid, result.items, options.preserveCards);
bindModelGridActions(grid);
}
function bindModelGridActions(grid: HTMLDivElement) {
if (grid.dataset.actionsBound === "true") return;
grid.dataset.actionsBound = "true";
grid.addEventListener("click", async (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest<HTMLButtonElement>("button[data-action][data-id]");
if (!button || !grid.contains(button)) return;
const id = Number(button.dataset.id);
const model = visibleModels.get(id);
try {
switch (button.dataset.action) {
case "preview": {
if (!model) return;
const { openModelPreview } = await import("./preview");
openModelPreview(model, loadModels);
break;
}
case "edit":
await editModel(id);
break;
case "import": {
if (!model) return;
const { createImportScenePayload, emitImportScenePayload } = await import("./preview");
const payload = createImportScenePayload({
model,
basePointEnabled: false,
basePoint: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 },
selectedOperationIndex: null
});
emitImportScenePayload(payload);
notify("导入场景数据已输出");
break;
}
case "download":
if (!model) return;
await downloadFileFromUrl(model.file_url, model.original_filename || model.name);
break;
case "delete":
await deleteModel(id);
break;
}
} catch (error) {
notifyError(error);
}
});
}
function renderModelGrid(grid: HTMLDivElement, items: ModelItem[], preserveCards = false) {
if (!preserveCards) {
grid.innerHTML = items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
return;
}
if (items.length === 0) {
grid.innerHTML = `<div class="empty-state">当前目录暂无模型</div>`;
return;
}
const existingCards = new Map(
Array.from(grid.querySelectorAll<HTMLElement>(".model-card[data-model-id]"))
.map((card) => [Number(card.dataset.modelId), card])
);
const fragment = document.createDocumentFragment();
for (const item of items) {
const existingCard = existingCards.get(item.id);
if (existingCard) {
fragment.append(existingCard);
continue;
}
const template = document.createElement("template");
template.innerHTML = renderModelCard(item).trim();
fragment.append(template.content);
}
grid.replaceChildren(fragment);
}
async function reloadFoldersAndModels(preserveModelCards = false) {
const { loadFolders } = await import("./folders");
await loadFolders({ reloadModels: false });
await loadModels({ preserveCards: preserveModelCards });
}
async function loadDictionaries(force = false) {
if (!force && dictionariesLoaded) {
syncDictionaryFilters();
return;
}
if (!force && dictionariesLoading) {
await dictionariesLoading;
syncDictionaryFilters();
return;
}
dictionariesLoading = api<DictionaryResponse>("/api/dictionaries")
.then((result) => {
appState.brands = result.brands;
appState.types = result.types;
dictionariesLoaded = true;
})
.finally(() => {
dictionariesLoading = null;
});
await dictionariesLoading;
syncDictionaryFilters();
}
function invalidateDictionaries() {
dictionariesLoaded = false;
}
function syncDictionaryFilters() {
syncDictionarySelect("#brandFilter", appState.brands, "全部品牌", appState.filters.brandId);
syncDictionarySelect("#typeFilter", appState.types, "全部类型", appState.filters.typeId);
}
function syncDictionarySelect(selector: string, items: { id: number; name: string }[], emptyText: string, value: string) {
const select = document.querySelector<HTMLSelectElement>(selector);
if (!select) return;
const current = select.value || value;
select.innerHTML = `<option value="">${emptyText}</option>` + items
.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`)
.join("");
select.value = current;
}
function renderModelCard(item: ModelItem) {
const canWrite = item.permissions.write;
const prop = item.properties ?? {};
const thumb = item.thumbnail_url
? `<img src="${resolveStorageUrl(item.thumbnail_url)}" alt="" />`
: `<div class="thumb-placeholder">暂无预览图</div>`;
return `
<article class="model-card" data-model-id="${item.id}">
<div class="model-info">
<strong>${escapeHtml(item.name)}</strong>
</div>
<div class="thumb">
${thumb}
<div class="thumb-actions">
<button data-action="preview" data-id="${item.id}">预览</button>
<button data-action="download" data-id="${item.id}">下载</button>
${canWrite ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
<button data-action="import" data-id="${item.id}">导入</button>
${canWrite ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
</div>
</div>
<dl>
<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>
</dl>
</article>
`;
}
function syncSelectedFolderActions() {
const currentFolder = appState.folders.find((folder) => folder.id === appState.selectedFolderId);
const canWrite = Boolean(currentFolder?.permissions.write);
const addModelBtn = document.querySelector<HTMLButtonElement>("#addModelBtn");
const addProcessModelBtn = document.querySelector<HTMLButtonElement>("#addProcessModelBtn");
const importSceneModelBtn = document.querySelector<HTMLButtonElement>("#importSceneModelBtn");
if (addModelBtn) addModelBtn.hidden = !canWrite;
if (addProcessModelBtn) addProcessModelBtn.hidden = !canWrite;
if (importSceneModelBtn) importSceneModelBtn.hidden = !canWrite;
}
function ensureImportSceneModelButton() {
if (document.querySelector("#importSceneModelBtn")) return;
const addModelBtn = document.querySelector<HTMLButtonElement>("#addModelBtn");
if (!addModelBtn) return;
const button = document.createElement("button");
button.id = "importSceneModelBtn";
button.type = "button";
button.textContent = "导入场景模型";
button.addEventListener("click", requestSceneModelImport);
addModelBtn.insertAdjacentElement("afterend", button);
}
function bindSceneModelBridge() {
if (sceneModelBridgeBound) return;
sceneModelBridgeBound = true;
window.addEventListener("message", (event) => {
const data = event.data as SceneModelMessage;
if (data?.type !== sceneModelMessageType && data?.type !== sceneModelErrorMessageType) return;
if (!isAllowedParentOrigin(event.origin)) return;
void handleSceneModelMessage(data);
});
}
function requestSceneModelImport() {
if (!appState.selectedFolderId) {
notify("请先选择目录");
return;
}
if (!window.parent || window.parent === window) {
notify("请在 DMT 主程序中使用导入场景模型");
return;
}
const requestId = createSceneModelRequestId();
pendingSceneModelRequestId = requestId;
clearSceneModelRequestTimer();
pendingSceneModelRequestTimer = window.setTimeout(() => {
if (pendingSceneModelRequestId !== requestId) return;
pendingSceneModelRequestId = "";
pendingSceneModelRequestTimer = null;
notifyError("主程序未响应导入场景模型请求,请确认模型库是在主程序面板中打开");
}, 15000);
window.parent.postMessage({
type: requestSceneModelMessageType,
requestId
}, resolveParentPostMessageOrigin());
// notify("已请求主程序导出当前选中模型");
}
async function handleSceneModelMessage(data: SceneModelMessage) {
console.log("Received scene model message:", data);
if (pendingSceneModelRequestId && data.requestId && data.requestId !== pendingSceneModelRequestId) return;
pendingSceneModelRequestId = "";
clearSceneModelRequestTimer();
if (data.type === sceneModelErrorMessageType) {
notifyError(data.message || "主程序导出模型失败");
return;
}
const payload = data.payload;
if (!payload?.modelBuffer) {
notifyError("主程序返回的模型数据为空");
return;
}
const fileName = ensureGlbFileName(payload.fileName || payload.modelName || "scene-model.glb");
const file = new File([payload.modelBuffer], fileName, { type: "model/gltf-binary" });
await openProcessModelUploadDialog({
file,
modelName: payload.modelName || modelNameFromFile(fileName),
operationTreeJson: payload.operationTreeJson || defaultOperationTree
});
}
function clearSceneModelRequestTimer() {
if (pendingSceneModelRequestTimer === null) return;
window.clearTimeout(pendingSceneModelRequestTimer);
pendingSceneModelRequestTimer = null;
}
function createSceneModelRequestId() {
return `scene-model-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function isAllowedParentOrigin(origin: string) {
const expectedOrigin = resolveParentOrigin();
return expectedOrigin === "*" || origin === expectedOrigin || isLocalDmtParentOrigin(origin);
}
function resolveParentPostMessageOrigin() {
const origin = resolveParentOrigin();
return origin === window.location.origin ? "*" : origin;
}
function resolveParentOrigin() {
try {
const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> };
const configuredOrigin = meta.env?.VITE_DMT_PARENT_ORIGIN;
if (configuredOrigin) return configuredOrigin;
if (document.referrer) return new URL(document.referrer).origin;
} catch {
return "*";
}
return "*";
}
function isLocalDmtParentOrigin(origin: string) {
try {
const url = new URL(origin);
const isLocalHost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
return isLocalHost && url.port === "3000";
} catch {
return false;
}
}
function ensureGlbFileName(fileName: string) {
return fileName.toLowerCase().endsWith(".glb") ? fileName : `${fileName}.glb`;
}
export async function uploadModelToBackend(payload: UploadModelPayload) {
const fileName = (payload.fileName || payload.file.name || payload.name).trim();
const displayName = (payload.name || modelNameFromFile(fileName)).trim();
if (!displayName) {
throw new Error("模型名称不能为空");
}
if (!fileName.toLowerCase().endsWith(".glb")) {
throw new Error("当前阶段只允许上传 .glb 模型");
}
const folderId = payload.folderId ?? appState.selectedFolderId;
if (!folderId) {
throw new Error("请先选择目录");
}
const form = new FormData();
form.set("folderId", String(folderId));
form.set("name", displayName);
form.set("brandName", payload.brandName ?? "");
form.set("typeName", payload.typeName ?? "");
form.set("operationTree", normalizeOperationTreeInput(payload.operationTree));
form.set("file", payload.file, fileName);
for (const [key, value] of Object.entries(payload.properties ?? {})) {
form.set(`prop.${key}`, value);
}
await api("/api/models/upload", {
method: "POST",
body: form
});
}
async function openUploadModelDialog() {
if (!appState.selectedFolderId) {
notify("请先选择目录");
return;
}
const state: UploadFormState = { file: null, files: [] };
await loadDictionaries();
const result = await formDialog<boolean>({
title: "增加模型",
width: 560,
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" multiple />
<strong>选择模型</strong>
<em>或拖拽一个或多个 .glb 模型到这里</em>
<small id="selectedFileName">未选择文件</small>
</div>
</label>
<label>
<span>模型名称</span>
<input id="modelUploadName" name="name" placeholder="选择文件后自动填入" />
</label>
<div class="popup-form-grid">
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
<label><span>型号</span><input name="model" /></label>
</div>
<div id="modelUploadStatus" class="upload-status" aria-live="polite" hidden></div>
${dictionaryDatalistHtml()}
</form>
`,
onOpen: () => bindUploadDialogEvents(state),
onSubmit: async () => {
if (state.files.length === 0) {
throw new Error("请选择 .glb 模型文件");
}
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
if (state.files.length === 1 && !name) {
throw new Error("模型名称不能为空");
}
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
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();
await uploadModelToBackend({
file,
name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name),
fileName: file.name,
brandName,
typeName,
operationTree: defaultOperationTree,
properties
});
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) {
notify("模型上传完成");
invalidateDictionaries();
await reloadFoldersAndModels();
}
}
export async function openProcessModelUploadDialog(input?: {
file?: File | null;
modelName?: string;
operationTreeJson?: string;
}) {
if (!appState.selectedFolderId) {
notify("请先选择目录");
return;
}
const state: UploadFormState = { file: input?.file ?? null, files: input?.file ? [input.file] : [] };
await loadDictionaries();
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
const result = await formDialog<boolean>({
title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}`,
width: 400,
height: 300,
blockPage: false,
body: `
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form">
<div class="popup-form-grid">
<label><span>模型名称</span><input id="processModelUploadName" name="name" value="${escapeHtml(initialName)}" autocomplete="off" /></label>
<label><span>品牌</span><input name="brandName" list="brandOptions" autocomplete="off" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" autocomplete="off" /></label>
<label><span>型号</span><input name="model" autocomplete="off" /></label>
</div>
${dictionaryDatalistHtml()}
</form>
`,
onOpen: () => bindProcessUploadDialogEvents(state),
onSubmit: async () => {
if (!state.file) {
throw new Error("请选择 .glb 模型文件");
}
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
const name = String(form.get("name") ?? "").trim();
await uploadModelToBackend({
file: state.file,
name,
fileName: state.file.name,
brandName: String(form.get("brandName") ?? ""),
typeName: String(form.get("typeName") ?? ""),
operationTree: String(form.get("operationTree") ?? ""),
properties: {
model: String(form.get("model") ?? "")
}
});
return true;
}
});
if (result) {
invalidateDictionaries();
await reloadFoldersAndModels();
}
}
function bindUploadDialogEvents(state: UploadFormState) {
const dropZone = document.querySelector<HTMLDivElement>("#modelDropZone")!;
const fileInput = document.querySelector<HTMLInputElement>("#modelUploadFile")!;
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
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.files = selectedFiles;
state.file = selectedFiles[0] ?? null;
renderSelectedFiles();
};
fileInput.addEventListener("change", () => {
if (fileInput.files) selectFiles(fileInput.files);
});
dropZone.addEventListener("click", (event) => {
if (event.target !== fileInput) fileInput.click();
});
dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
dropZone.classList.add("is-dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("is-dragover");
});
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragover");
const 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;
}
function setUploadDialogBusy(state: UploadFormState, busy: boolean) {
const form = document.querySelector<HTMLFormElement>("#modelUploadPopupForm");
if (!form) return;
form.classList.toggle("is-uploading", busy);
form.querySelectorAll<HTMLInputElement>("input").forEach((input) => {
input.disabled = busy || (input.id === "modelUploadName" && state.files.length > 1);
});
}
function bindProcessUploadDialogEvents(state: UploadFormState) {
// const dropZone = document.querySelector<HTMLDivElement>("#processModelDropZone")!;
// const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!;
// const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!;
// const selectedFileName = document.querySelector<HTMLElement>("#processSelectedFileName")!;
// const selectFile = (file: File) => {
// if (!file.name.toLowerCase().endsWith(".glb")) {
// notify("当前阶段只允许上传 .glb 模型");
// return;
// }
// state.file = file;
// selectedFileName.textContent = file.name;
// if (!nameInput.value.trim()) {
// nameInput.value = file.name;
// }
// };
// if (state.file) {
// selectedFileName.textContent = state.file.name;
// if (!nameInput.value.trim()) {
// nameInput.value = state.file.name;
// }
// }
// fileInput.addEventListener("change", () => {
// const file = fileInput.files?.[0];
// if (file) selectFile(file);
// });
// dropZone.addEventListener("click", (event) => {
// if (event.target !== fileInput) fileInput.click();
// });
// dropZone.addEventListener("dragover", (event) => {
// event.preventDefault();
// dropZone.classList.add("is-dragover");
// });
// dropZone.addEventListener("dragleave", () => {
// dropZone.classList.remove("is-dragover");
// });
// dropZone.addEventListener("drop", (event) => {
// event.preventDefault();
// dropZone.classList.remove("is-dragover");
// const file = event.dataTransfer?.files?.[0];
// if (file) selectFile(file);
// });
}
function normalizeOperationTreeInput(value?: string) {
const clean = value?.trim();
if (!clean) return defaultOperationTree;
let parsed: unknown;
try {
parsed = JSON.parse(clean);
} catch {
throw new Error("工艺数据必须是合法 JSON 字符串");
}
if (Array.isArray(parsed)) {
return JSON.stringify({ OperationTree: JSON.stringify(parsed) });
}
if (parsed && typeof parsed === "object" && "OperationTree" in parsed) {
const normalized = { ...(parsed as Record<string, unknown>) };
const operationTree = normalized.OperationTree;
if (typeof operationTree === "string") {
JSON.parse(operationTree);
normalized.OperationTree = operationTree;
normalizeOptionalJsonListField(normalized, "ModelProcess");
normalizeOptionalJsonListField(normalized, "ProcesslLabelList");
return JSON.stringify(normalized);
}
if (Array.isArray(operationTree)) {
normalized.OperationTree = JSON.stringify(operationTree);
normalizeOptionalJsonListField(normalized, "ModelProcess");
normalizeOptionalJsonListField(normalized, "ProcesslLabelList");
return JSON.stringify(normalized);
}
}
throw new Error('工艺数据格式必须是 {"OperationTree":"[...]"} 或 OperationTree[] 数组');
}
function normalizeOptionalJsonListField(target: Record<string, unknown>, field: string) {
const value = target[field];
if (value === undefined || value === null || value === "") return;
if (typeof value === "string") {
JSON.parse(value);
return;
}
if (Array.isArray(value)) {
target[field] = JSON.stringify(value);
}
}
async function editModel(id: number) {
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 }>({
title: "编辑模型",
width: 520,
height: 390,
blockPage: false,
body: `
<form id="modelEditPopupForm" class="popup-form">
<label><span>模型名称</span><input name="name" value="${escapeHtml(oldName)}" /></label>
<div class="popup-form-grid">
<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>
</div>
${dictionaryDatalistHtml()}
</form>
`,
onSubmit: () => {
const form = new FormData(document.querySelector<HTMLFormElement>("#modelEditPopupForm")!);
const name = String(form.get("name") ?? "").trim();
if (!name) {
throw new Error("模型名称不能为空");
}
return {
name,
brandName: String(form.get("brandName") ?? ""),
typeName: String(form.get("typeName") ?? ""),
model: String(form.get("model") ?? "")
};
}
});
if (!result) return;
await api(`/api/models/${id}`, {
method: "PUT",
body: JSON.stringify({
name: result.name,
brandName: result.brandName,
typeName: result.typeName,
properties: {
model: result.model
}
})
});
invalidateDictionaries();
await loadModels();
}
function dictionaryDatalistHtml() {
return `
<datalist id="brandOptions">
${appState.brands.map((item) => `<option value="${escapeHtml(item.name)}"></option>`).join("")}
</datalist>
<datalist id="typeOptions">
${appState.types.map((item) => `<option value="${escapeHtml(item.name)}"></option>`).join("")}
</datalist>
`;
}
async function deleteModel(id: number) {
const confirmed = await confirmDialog("确认删除该模型?");
if (!confirmed) return;
await api(`/api/models/${id}`, { method: "DELETE" });
await reloadFoldersAndModels(true);
}