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 { escapeHtml, modelNameFromFile } from "../../../utils/format"; import { appState } from "../appState"; type UploadFormState = { file: File | null; }; type UploadModelPayload = { file: File; name: string; fileName?: string; folderId?: number; brandName?: string; typeName?: string; operationTree?: string; properties?: Record; }; 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 | null = null; let sceneModelBridgeBound = false; let pendingSceneModelRequestId = ""; let pendingSceneModelRequestTimer: number | null = null; let visibleModels = new Map(); 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("#brandFilter")!.addEventListener("change", async (event) => { appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value; appState.page = 1; await loadModels(); }); document.querySelector("#typeFilter")!.addEventListener("change", async (event) => { appState.filters.typeId = (event.currentTarget as HTMLSelectElement).value; appState.page = 1; await loadModels(); }); document.querySelector("#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("#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("#brandFilter")!.value = ""; document.querySelector("#typeFilter")!.value = ""; document.querySelector("#keywordFilter")!.value = ""; appState.page = 1; await loadModels(); }); } export async function loadModels(options: { preserveCards?: boolean } = {}) { const grid = document.querySelector("#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(`/api/models?${params.toString()}`); if (result.items.length === 0 && appState.page > 1) { appState.page -= 1; return loadModels(); } renderPagination({ container: document.querySelector("#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("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 "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("") || `
当前目录暂无模型
`; return; } if (items.length === 0) { grid.innerHTML = `
当前目录暂无模型
`; return; } const existingCards = new Map( Array.from(grid.querySelectorAll(".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("/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(selector); if (!select) return; const current = select.value || value; select.innerHTML = `` + items .map((item) => ``) .join(""); select.value = current; } function renderModelCard(item: ModelItem) { const canWrite = item.permissions.write; const prop = item.properties ?? {}; const thumb = item.thumbnail_url ? `` : `
暂无预览图
`; return `
${escapeHtml(item.name)}
${thumb}
${canWrite ? `` : ""} ${canWrite ? `` : ""}
品牌
${escapeHtml(item.brand_name ?? "")}
类型
${escapeHtml(item.type_name ?? "")}
型号
${escapeHtml(prop.model ?? "")}
价钱
${escapeHtml(prop.price ?? "")}
重量
${escapeHtml(prop.weight ?? "")}
`; } function syncSelectedFolderActions() { const currentFolder = appState.folders.find((folder) => folder.id === appState.selectedFolderId); const canWrite = Boolean(currentFolder?.permissions.write); const addModelBtn = document.querySelector("#addModelBtn"); const addProcessModelBtn = document.querySelector("#addProcessModelBtn"); const importSceneModelBtn = document.querySelector("#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("#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) { 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 }; 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", modelNameFromFile(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 }; await loadDictionaries(); const result = await formDialog({ title: "增加模型", width: 560, height: 520, blockPage: false, body: ` `, onOpen: () => bindUploadDialogEvents(state), onSubmit: async () => { if (!state.file) { throw new Error("请选择 .glb 模型文件"); } const name = document.querySelector("#modelUploadName")?.value.trim(); if (!name) { throw new Error("模型名称不能为空"); } const form = new FormData(document.querySelector("#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") ?? "") } }); return true; } }); if (result) { 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 }; await loadDictionaries(); const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : ""); const result = await formDialog({ title: "上传工艺模型", width: 640, height: 650, blockPage: false, body: ` `, onOpen: () => bindProcessUploadDialogEvents(state), onSubmit: async () => { if (!state.file) { throw new Error("请选择 .glb 模型文件"); } const form = new FormData(document.querySelector("#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") ?? ""), price: String(form.get("price") ?? ""), weight: String(form.get("weight") ?? "") } }); return true; } }); if (result) { invalidateDictionaries(); await reloadFoldersAndModels(); } } function bindUploadDialogEvents(state: UploadFormState) { const dropZone = document.querySelector("#modelDropZone")!; const fileInput = document.querySelector("#modelUploadFile")!; const nameInput = document.querySelector("#modelUploadName")!; const selectedFileName = document.querySelector("#selectedFileName")!; 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 = modelNameFromFile(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 bindProcessUploadDialogEvents(state: UploadFormState) { const dropZone = document.querySelector("#processModelDropZone")!; const fileInput = document.querySelector("#processModelUploadFile")!; const nameInput = document.querySelector("#processModelUploadName")!; const selectedFileName = document.querySelector("#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) }; 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, 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(`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 }>({ title: "编辑模型", width: 520, height: 390, blockPage: false, body: ` `, onSubmit: () => { const form = new FormData(document.querySelector("#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") ?? ""), price: String(form.get("price") ?? ""), weight: String(form.get("weight") ?? "") }; } }); 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, price: result.price, weight: result.weight } }) }); invalidateDictionaries(); await loadModels(); } function dictionaryDatalistHtml() { return ` ${appState.brands.map((item) => ``).join("")} ${appState.types.map((item) => ``).join("")} `; } async function deleteModel(id: number) { const confirmed = await confirmDialog("确认删除该模型?"); if (!confirmed) return; await api(`/api/models/${id}`, { method: "DELETE" }); await reloadFoldersAndModels(true); }