948 lines
34 KiB
TypeScript
948 lines
34 KiB
TypeScript
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);
|
||
}
|