Compare commits

...

10 Commits

Author SHA1 Message Date
zhangshun
a059805458 调整模型库上传模型的字段 2026-06-01 14:14:16 +08:00
zhangshun
6206c9556b 优化模型删除后的列表刷新 2026-06-01 13:26:31 +08:00
zhangshun
00f7dc1686 增加模型库无工艺数据播放提示 2026-06-01 13:07:50 +08:00
zhangshun
57d3137640 修复模型库跨端口消息发送 2026-06-01 10:40:04 +08:00
zhangshun
cc58a6e98d 固定模型库工艺预览帧间隔 2026-06-01 10:18:08 +08:00
zhangshun
b6c3bd9922 修复模型库工艺预览播放速度 2026-06-01 10:12:38 +08:00
zhangshun
c14a6aa75c 增强场景模型导入请求反馈 2026-06-01 10:06:55 +08:00
zhangshun
692d61105e 刷新模型库目录模型数量 2026-06-01 09:57:35 +08:00
zhangshun
fcec4f3db4 修复模型库空请求体删除失败 2026-06-01 09:50:52 +08:00
zhangshun
3dfed3a2df 统一模型库确认框调用方式 2026-06-01 09:40:36 +08:00
7 changed files with 248 additions and 131 deletions

View File

@@ -115,18 +115,18 @@
overflow: auto; overflow: auto;
padding: 10px 12px; padding: 10px 12px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
align-content: start; align-content: start;
gap: 10px; gap: 10px;
} }
.model-card { .model-card {
display: grid; display: grid;
gap: 8px; gap: 5px;
background: #ffffff; background: #ffffff;
border: 1px solid #d7e0e8; border: 1px solid #d7e0e8;
border-radius: 6px; border-radius: 6px;
padding: 8px; padding: 5px;
} }
.thumb { .thumb {

View File

@@ -63,7 +63,8 @@ async function runFolderAction(action: () => Promise<void>) {
} }
} }
export async function loadFolders() { export async function loadFolders(options: { reloadModels?: boolean } = {}) {
const reloadModels = options.reloadModels ?? true;
const result = await api<FolderTreeResponse>("/api/folders"); const result = await api<FolderTreeResponse>("/api/folders");
appState.folders = result.folders; appState.folders = result.folders;
const root = appState.folders.find((folder) => folder.parent_id === null); const root = appState.folders.find((folder) => folder.parent_id === null);
@@ -125,8 +126,10 @@ export async function loadFolders() {
if (appState.selectedFolderId) { if (appState.selectedFolderId) {
$("#folderTree").on("ready.jstree", () => { $("#folderTree").on("ready.jstree", () => {
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId)); $("#folderTree").jstree(true).select_node(String(appState.selectedFolderId), true);
}); });
} }
await loadModels(); if (reloadModels) {
await loadModels();
}
} }

View File

@@ -35,6 +35,7 @@ type SceneModelMessagePayload = {
type SceneModelMessage = { type SceneModelMessage = {
type?: string; type?: string;
requestId?: string;
payload?: SceneModelMessagePayload; payload?: SceneModelMessagePayload;
message?: string; message?: string;
}; };
@@ -42,6 +43,9 @@ type SceneModelMessage = {
let dictionariesLoaded = false; let dictionariesLoaded = false;
let dictionariesLoading: Promise<void> | null = null; let dictionariesLoading: Promise<void> | null = null;
let sceneModelBridgeBound = false; let sceneModelBridgeBound = false;
let pendingSceneModelRequestId = "";
let pendingSceneModelRequestTimer: number | null = null;
let visibleModels = new Map<number, ModelItem>();
export function bindModelActions() { export function bindModelActions() {
const isAdmin = getCurrentUser()?.role === "admin"; const isAdmin = getCurrentUser()?.role === "admin";
@@ -120,7 +124,7 @@ export function bindModelActions() {
}); });
} }
export async function loadModels() { export async function loadModels(options: { preserveCards?: boolean } = {}) {
const grid = document.querySelector<HTMLDivElement>("#modelGrid"); const grid = document.querySelector<HTMLDivElement>("#modelGrid");
if (!grid || !appState.selectedFolderId) return; if (!grid || !appState.selectedFolderId) return;
await loadDictionaries(); await loadDictionaries();
@@ -150,37 +154,85 @@ export async function loadModels() {
await loadModels(); await loadModels();
} }
}); });
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`; visibleModels = new Map(result.items.map((item) => [item.id, item]));
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => { renderModelGrid(grid, result.items, options.preserveCards);
const model = result.items.find((item) => item.id === Number(button.dataset.id)); bindModelGridActions(grid);
if (model) { }
button.addEventListener("click", async () => {
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"); const { openModelPreview } = await import("./preview");
openModelPreview(model, loadModels); 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);
} }
}); });
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => { }
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
}); function renderModelGrid(grid: HTMLDivElement, items: ModelItem[], preserveCards = false) {
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => { if (!preserveCards) {
const model = result.items.find((item) => item.id === Number(button.dataset.id)); grid.innerHTML = items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
if (!model) return; return;
button.addEventListener("click", async () => { }
const { createImportScenePayload, emitImportScenePayload } = await import("./preview"); if (items.length === 0) {
const payload = createImportScenePayload({ grid.innerHTML = `<div class="empty-state">当前目录暂无模型</div>`;
model, return;
basePointEnabled: false, }
basePoint: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 }, const existingCards = new Map(
selectedOperationIndex: null Array.from(grid.querySelectorAll<HTMLElement>(".model-card[data-model-id]"))
}); .map((card) => [Number(card.dataset.modelId), card])
emitImportScenePayload(payload); );
notify("导入场景数据已输出"); const fragment = document.createDocumentFragment();
}); for (const item of items) {
}); const existingCard = existingCards.get(item.id);
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => { if (existingCard) {
button.addEventListener("click", () => deleteModel(Number(button.dataset.id))); 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) { async function loadDictionaries(force = false) {
@@ -300,13 +352,27 @@ function requestSceneModelImport() {
notify("请在 DMT 主程序中使用导入场景模型"); notify("请在 DMT 主程序中使用导入场景模型");
return; return;
} }
const requestId = createSceneModelRequestId();
pendingSceneModelRequestId = requestId;
clearSceneModelRequestTimer();
pendingSceneModelRequestTimer = window.setTimeout(() => {
if (pendingSceneModelRequestId !== requestId) return;
pendingSceneModelRequestId = "";
pendingSceneModelRequestTimer = null;
notifyError("主程序未响应导入场景模型请求,请确认模型库是在主程序面板中打开");
}, 15000);
window.parent.postMessage({ window.parent.postMessage({
type: requestSceneModelMessageType type: requestSceneModelMessageType,
}, resolveParentOrigin()); requestId
notify("已请求主程序导出当前选中模型"); }, resolveParentPostMessageOrigin());
// notify("已请求主程序导出当前选中模型");
} }
async function handleSceneModelMessage(data: SceneModelMessage) { 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) { if (data.type === sceneModelErrorMessageType) {
notifyError(data.message || "主程序导出模型失败"); notifyError(data.message || "主程序导出模型失败");
return; return;
@@ -327,9 +393,24 @@ async function handleSceneModelMessage(data: SceneModelMessage) {
}); });
} }
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) { function isAllowedParentOrigin(origin: string) {
const expectedOrigin = resolveParentOrigin(); const expectedOrigin = resolveParentOrigin();
return expectedOrigin === "*" || origin === expectedOrigin; return expectedOrigin === "*" || origin === expectedOrigin || isLocalDmtParentOrigin(origin);
}
function resolveParentPostMessageOrigin() {
const origin = resolveParentOrigin();
return origin === window.location.origin ? "*" : origin;
} }
function resolveParentOrigin() { function resolveParentOrigin() {
@@ -344,6 +425,16 @@ function resolveParentOrigin() {
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) { function ensureGlbFileName(fileName: string) {
return fileName.toLowerCase().endsWith(".glb") ? fileName : `${fileName}.glb`; return fileName.toLowerCase().endsWith(".glb") ? fileName : `${fileName}.glb`;
} }
@@ -446,7 +537,7 @@ async function openUploadModelDialog() {
if (result) { if (result) {
invalidateDictionaries(); invalidateDictionaries();
await loadModels(); await reloadFoldersAndModels();
} }
} }
@@ -464,40 +555,20 @@ export async function openProcessModelUploadDialog(input?: {
await loadDictionaries(); await loadDictionaries();
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : ""); const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
const result = await formDialog<boolean>({ const result = await formDialog<boolean>({
title: "上传工艺模型", title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}`,
width: 640, width: 400,
height: 650, height: 300,
blockPage: false, blockPage: false,
body: ` body: `
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form"> <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"> <div class="popup-form-grid">
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label> <label><span>模型名称</span><input id="processModelUploadName" name="name" value="${escapeHtml(initialName)}" autocomplete="off" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" /></label> <label><span>品牌</span><input name="brandName" list="brandOptions" autocomplete="off" /></label>
<label><span>型</span><input name="model" /></label> <label><span>型</span><input name="typeName" list="typeOptions" autocomplete="off" /></label>
<label><span>价钱</span><input name="price" /></label> <label><span>型号</span><input name="model" autocomplete="off" /></label>
<label><span>重量</span><input name="weight" /></label> <label><span>价钱</span><input name="price" autocomplete="off" /></label>
<label><span>重量</span><input name="weight" autocomplete="off" /></label>
</div> </div>
<label>
<span>工艺数据 JSON</span>
<textarea id="processOperationTreeJson" name="operationTree" placeholder='可以粘贴 {"OperationTree":"[...]"} 或 OperationTree[] 数组 JSON'>${escapeHtml(input?.operationTreeJson ?? "")}</textarea>
</label>
${dictionaryDatalistHtml()} ${dictionaryDatalistHtml()}
</form> </form>
`, `,
@@ -527,7 +598,7 @@ export async function openProcessModelUploadDialog(input?: {
if (result) { if (result) {
invalidateDictionaries(); invalidateDictionaries();
await loadModels(); await reloadFoldersAndModels();
} }
} }
@@ -574,52 +645,52 @@ function bindUploadDialogEvents(state: UploadFormState) {
} }
function bindProcessUploadDialogEvents(state: UploadFormState) { function bindProcessUploadDialogEvents(state: UploadFormState) {
const dropZone = document.querySelector<HTMLDivElement>("#processModelDropZone")!; // const dropZone = document.querySelector<HTMLDivElement>("#processModelDropZone")!;
const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!; // const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!;
const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!; // const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!;
const selectedFileName = document.querySelector<HTMLElement>("#processSelectedFileName")!; // const selectedFileName = document.querySelector<HTMLElement>("#processSelectedFileName")!;
const selectFile = (file: File) => { // const selectFile = (file: File) => {
if (!file.name.toLowerCase().endsWith(".glb")) { // if (!file.name.toLowerCase().endsWith(".glb")) {
notify("当前阶段只允许上传 .glb 模型"); // notify("当前阶段只允许上传 .glb 模型");
return; // return;
} // }
state.file = file; // state.file = file;
selectedFileName.textContent = file.name; // selectedFileName.textContent = file.name;
if (!nameInput.value.trim()) { // if (!nameInput.value.trim()) {
nameInput.value = file.name; // nameInput.value = file.name;
} // }
}; // };
if (state.file) { // if (state.file) {
selectedFileName.textContent = state.file.name; // selectedFileName.textContent = state.file.name;
if (!nameInput.value.trim()) { // if (!nameInput.value.trim()) {
nameInput.value = state.file.name; // nameInput.value = state.file.name;
} // }
} // }
fileInput.addEventListener("change", () => { // fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0]; // const file = fileInput.files?.[0];
if (file) selectFile(file); // if (file) selectFile(file);
}); // });
dropZone.addEventListener("click", (event) => { // dropZone.addEventListener("click", (event) => {
if (event.target !== fileInput) fileInput.click(); // if (event.target !== fileInput) fileInput.click();
}); // });
dropZone.addEventListener("dragover", (event) => { // dropZone.addEventListener("dragover", (event) => {
event.preventDefault(); // event.preventDefault();
dropZone.classList.add("is-dragover"); // dropZone.classList.add("is-dragover");
}); // });
dropZone.addEventListener("dragleave", () => { // dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("is-dragover"); // dropZone.classList.remove("is-dragover");
}); // });
dropZone.addEventListener("drop", (event) => { // dropZone.addEventListener("drop", (event) => {
event.preventDefault(); // event.preventDefault();
dropZone.classList.remove("is-dragover"); // dropZone.classList.remove("is-dragover");
const file = event.dataTransfer?.files?.[0]; // const file = event.dataTransfer?.files?.[0];
if (file) selectFile(file); // if (file) selectFile(file);
}); // });
} }
function normalizeOperationTreeInput(value?: string) { function normalizeOperationTreeInput(value?: string) {
@@ -740,5 +811,5 @@ async function deleteModel(id: number) {
const confirmed = await confirmDialog("确认删除该模型?"); const confirmed = await confirmDialog("确认删除该模型?");
if (!confirmed) return; if (!confirmed) return;
await api(`/api/models/${id}`, { method: "DELETE" }); await api(`/api/models/${id}`, { method: "DELETE" });
await loadModels(); await reloadFoldersAndModels(true);
} }

View File

@@ -258,7 +258,7 @@ export function emitImportScenePayload(payload: PreviewImportScenePayload) {
window.parent.postMessage({ window.parent.postMessage({
type: "DMT_MODEL_LIBRARY_IMPORT_SCENE", type: "DMT_MODEL_LIBRARY_IMPORT_SCENE",
payload: buildParentImportScenePayload(payload) payload: buildParentImportScenePayload(payload)
}, resolveParentOrigin()); }, resolveParentPostMessageOrigin());
} catch (error) { } catch (error) {
notifyError(error); notifyError(error);
} }
@@ -280,6 +280,11 @@ function buildParentImportScenePayload(payload: PreviewImportScenePayload): Pare
}; };
} }
function resolveParentPostMessageOrigin() {
const origin = resolveParentOrigin();
return origin === window.location.origin ? "*" : origin;
}
function resolveParentOrigin() { function resolveParentOrigin() {
try { try {
const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> }; const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> };
@@ -624,6 +629,10 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
}); });
document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => { document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
if (operations.length === 0) {
notify("当前模型暂无工艺数据");
return;
}
const operation = getSelectedOperation(operations); const operation = getSelectedOperation(operations);
updateStatus(operation); updateStatus(operation);
if (!operation) { if (!operation) {
@@ -634,7 +643,7 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
notifyError(`工艺播放数据解析失败:${operation.parseError}`); notifyError(`工艺播放数据解析失败:${operation.parseError}`);
return; return;
} }
if (!operation.parsedCraftPlayData) { if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
notify("当前工艺暂无播放数据"); notify("当前工艺暂无播放数据");
return; return;
} }
@@ -648,10 +657,14 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
} }
function startPreviewPlayback(operation: PreviewOperation) { function startPreviewPlayback(operation: PreviewOperation) {
if (!runtime?.context.modelObject || !operation.parsedCraftPlayData) { if (!runtime?.context.modelObject) {
notify("模型还未加载完成"); notify("模型还未加载完成");
return; return;
} }
if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
notify("当前工艺暂无播放数据");
return;
}
if (previewPlayback.isPlaying && previewPlayback.operation === operation) { if (previewPlayback.isPlaying && previewPlayback.operation === operation) {
return; return;
@@ -868,13 +881,7 @@ function resetPreviewPlaybackScene() {
} }
function buildPreviewFrameTimes(frames: P_OPERATION["OPERATION"]["frames"]) { function buildPreviewFrameTimes(frames: P_OPERATION["OPERATION"]["frames"]) {
let previousTime = 0; return frames.map((_, index) => index * DEFAULT_PREVIEW_FRAME_INTERVAL_MS);
return frames.map((frame, index) => {
const parsed = Number(frame.time);
const timeMs = Number.isFinite(parsed) ? Math.max(0, parsed * SECOND_MS) : index * DEFAULT_PREVIEW_FRAME_INTERVAL_MS;
previousTime = index === 0 ? timeMs : Math.max(previousTime + 1, timeMs);
return previousTime;
});
} }
function resolvePreviewFrameIndex(currentTimeMs: number, frameTimesMs: number[]) { function resolvePreviewFrameIndex(currentTimeMs: number, frameTimesMs: number[]) {

View File

@@ -6,10 +6,11 @@ function authHeaders(): Record<string, string> {
} }
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> { export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
const hasBody = options.body !== undefined && options.body !== null;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers: { headers: {
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
...authHeaders(), ...authHeaders(),
...((options.headers as Record<string, string> | undefined) ?? {}) ...((options.headers as Record<string, string> | undefined) ?? {})
} as HeadersInit } as HeadersInit
@@ -20,4 +21,3 @@ export async function api<T>(url: string, options: RequestInit = {}): Promise<T>
} }
return data as T; return data as T;
} }

View File

@@ -1,4 +1,4 @@
import { w2confirm, w2popup, w2utils } from "../vendor/w2ui"; import { w2popup, w2utils } from "../vendor/w2ui";
type PopupActionEvent = { type PopupActionEvent = {
detail: { detail: {
@@ -23,14 +23,27 @@ export function notifyError(error: unknown) {
export function confirmDialog(message: string, title = "确认") { export function confirmDialog(message: string, title = "确认") {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
w2confirm({ let settled = false;
msg: message, const settle = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
w2utils.confirm({
box: "body",
title, title,
yes: "确定", text: message,
no: "取消" btn_yes: {
}, undefined, (action: string) => { text: "确定"
resolve(action === "yes" || action === "Yes"); },
}); btn_no: {
text: "取消"
}
})
.yes(() => settle(true))
.no(() => settle(false))
.close(() => settle(false));
}); });
} }

View File

@@ -28,6 +28,29 @@ type W2Utils = {
title?: string; title?: string;
text?: string; text?: string;
}): unknown; }): unknown;
confirm(options: {
box?: string | HTMLElement;
title?: string;
text?: string;
btn_yes?: {
text?: string;
class?: string;
style?: string;
attrs?: string;
};
btn_no?: {
text?: string;
class?: string;
style?: string;
attrs?: string;
};
}): W2ConfirmPromise;
};
type W2ConfirmPromise = {
yes(callback: (event: unknown) => void): W2ConfirmPromise;
no(callback: (event: unknown) => void): W2ConfirmPromise;
close(callback: (event: unknown) => void): W2ConfirmPromise;
}; };
type W2LayoutOptions = { type W2LayoutOptions = {