优化模型上传与预览体验

This commit is contained in:
zhangshun
2026-05-25 20:35:07 +08:00
parent a31c620420
commit efddf5adee
7 changed files with 878 additions and 79 deletions

View File

@@ -10,6 +10,17 @@ 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: "[]" });
export function bindModelActions() {
@@ -43,6 +54,14 @@ export function bindModelActions() {
}
});
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;
@@ -120,8 +139,18 @@ export async function loadModels() {
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
button.addEventListener("click", () => {
notify("导入功能预留,后续接入当前模型的导入逻辑");
const model = result.items.find((item) => item.id === Number(button.dataset.id));
if (!model) return;
button.addEventListener("click", async () => {
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("导入场景数据已输出");
});
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
@@ -182,7 +211,40 @@ 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");
if (addModelBtn) addModelBtn.hidden = !canWrite;
if (addProcessModelBtn) addProcessModelBtn.hidden = !canWrite;
}
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() {
@@ -232,19 +294,97 @@ async function openUploadModelDialog() {
throw new Error("模型名称不能为空");
}
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
const payload = new FormData();
payload.set("folderId", String(appState.selectedFolderId));
payload.set("name", name);
payload.set("brandName", String(form.get("brandName") ?? ""));
payload.set("typeName", String(form.get("typeName") ?? ""));
payload.set("operationTree", defaultOperationTree);
payload.set("file", state.file);
for (const key of ["model", "price", "weight"]) {
payload.set(`prop.${key}`, String(form.get(key) ?? ""));
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) {
await loadModels();
}
}
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,
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 模型文件");
}
await api("/api/models/upload", {
method: "POST",
body: payload
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
const name = String(form.get("name") ?? "").trim();
await uploadModelToBackend({
file: state.file,
name,
fileName: name || 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;
}
@@ -297,6 +437,83 @@ function bindUploadDialogEvents(state: UploadFormState) {
});
}
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 operationTree = (parsed as { OperationTree: unknown }).OperationTree;
if (typeof operationTree === "string") {
JSON.parse(operationTree);
return JSON.stringify({ OperationTree: operationTree });
}
if (Array.isArray(operationTree)) {
return JSON.stringify({ OperationTree: JSON.stringify(operationTree) });
}
}
throw new Error('工艺数据格式必须是 {"OperationTree":"[...]"} 或 OperationTree[] 数组');
}
async function editModel(id: number) {
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
const oldName = card?.querySelector("strong")?.textContent ?? "";