835 lines
29 KiB
TypeScript
835 lines
29 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 { 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<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 "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="${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>
|
|
${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>
|
|
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
|
|
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</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) {
|
|
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", 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<boolean>({
|
|
title: "增加模型",
|
|
width: 560,
|
|
height: 520,
|
|
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" />
|
|
<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>
|
|
<label><span>价钱</span><input name="price" /></label>
|
|
<label><span>重量</span><input name="weight" /></label>
|
|
</div>
|
|
${dictionaryDatalistHtml()}
|
|
</form>
|
|
`,
|
|
onOpen: () => bindUploadDialogEvents(state),
|
|
onSubmit: async () => {
|
|
if (!state.file) {
|
|
throw new Error("请选择 .glb 模型文件");
|
|
}
|
|
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
|
if (!name) {
|
|
throw new Error("模型名称不能为空");
|
|
}
|
|
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
|
await uploadModelToBackend({
|
|
file: state.file,
|
|
name,
|
|
fileName: state.file.name,
|
|
brandName: String(form.get("brandName") ?? ""),
|
|
typeName: String(form.get("typeName") ?? ""),
|
|
operationTree: defaultOperationTree,
|
|
properties: {
|
|
model: String(form.get("model") ?? ""),
|
|
price: String(form.get("price") ?? ""),
|
|
weight: String(form.get("weight") ?? "")
|
|
}
|
|
});
|
|
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<boolean>({
|
|
title: "上传工艺模型",
|
|
width: 640,
|
|
height: 650,
|
|
blockPage: false,
|
|
body: `
|
|
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form">
|
|
<div class="upload-target-path">
|
|
<span>上传目录</span>
|
|
<strong>${escapeHtml(appState.selectedFolderName || "当前目录")}</strong>
|
|
</div>
|
|
<label>
|
|
<span>模型文件</span>
|
|
<div id="processModelDropZone" class="drop-zone">
|
|
<input id="processModelUploadFile" type="file" accept=".glb" />
|
|
<strong>选择模型</strong>
|
|
<em>或拖拽 .glb 模型到这里</em>
|
|
<small id="processSelectedFileName">${escapeHtml(input?.file?.name ?? "未选择文件")}</small>
|
|
</div>
|
|
</label>
|
|
<label>
|
|
<span>模型名称</span>
|
|
<input id="processModelUploadName" name="name" placeholder="例如 AA.glb" value="${escapeHtml(initialName)}" />
|
|
</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>
|
|
<label><span>价钱</span><input name="price" /></label>
|
|
<label><span>重量</span><input name="weight" /></label>
|
|
</div>
|
|
<label>
|
|
<span>工艺数据 JSON</span>
|
|
<textarea id="processOperationTreeJson" name="operationTree" placeholder='可以粘贴 {"OperationTree":"[...]"} 或 OperationTree[] 数组 JSON'>${escapeHtml(input?.operationTreeJson ?? "")}</textarea>
|
|
</label>
|
|
${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") ?? ""),
|
|
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<HTMLDivElement>("#modelDropZone")!;
|
|
const fileInput = document.querySelector<HTMLInputElement>("#modelUploadFile")!;
|
|
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
|
const selectedFileName = document.querySelector<HTMLElement>("#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<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; price: string; weight: 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>
|
|
<label><span>价钱</span><input name="price" value="${escapeHtml(card?.querySelector("dl div:nth-child(4) dd")?.textContent ?? "")}" /></label>
|
|
<label><span>重量</span><input name="weight" value="${escapeHtml(card?.querySelector("dl div:nth-child(5) dd")?.textContent ?? "")}" /></label>
|
|
</div>
|
|
${dictionaryDatalistHtml()}
|
|
</form>
|
|
`,
|
|
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") ?? ""),
|
|
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 `
|
|
<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);
|
|
}
|