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; }; 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 "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("") || `
当前目录暂无模型
`; 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 ?? "")}
`; } 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) { 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 }; 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({ title: "增加模型", width: 560, height: 430, blockPage: false, body: ` `, onOpen: () => bindUploadDialogEvents(state), onSubmit: async () => { if (state.files.length === 0) { throw new Error("请选择 .glb 模型文件"); } const name = document.querySelector("#modelUploadName")?.value.trim(); if (state.files.length === 1 && !name) { throw new Error("模型名称不能为空"); } const form = new FormData(document.querySelector("#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({ title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}】`, width: 400, height: 300, 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") ?? "") } }); 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 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 = ` 已选择 ${state.files.length} 个模型,名称按文件名自动生成 ${state.files.map((file) => escapeHtml(file.name)).join("
")}
`; 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("#modelUploadStatus"); if (!status) return; status.hidden = false; status.textContent = message; } function waitForUploadStatusPaint() { return new Promise((resolve) => { window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); }); } function showUploadProgressOverlay(total: number) { let overlay = document.querySelector("#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 = `
模型上传中
正在准备上传...
0/${total}
`; 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("#modelUploadProgressMessage"); const meta = document.querySelector("#modelUploadProgressMeta"); const file = document.querySelector("#modelUploadProgressFile"); const bar = document.querySelector("#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("#modelUploadProgressOverlay"); if (overlay) overlay.hidden = true; } function setUploadDialogBusy(state: UploadFormState, busy: boolean) { const form = document.querySelector("#modelUploadPopupForm"); if (!form) return; form.classList.toggle("is-uploading", busy); form.querySelectorAll("input").forEach((input) => { input.disabled = busy || (input.id === "modelUploadName" && state.files.length > 1); }); } 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 }>({ 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") ?? "") }; } }); 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 ` ${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); }