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;
padding: 10px 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
align-content: start;
gap: 10px;
}
.model-card {
display: grid;
gap: 8px;
gap: 5px;
background: #ffffff;
border: 1px solid #d7e0e8;
border-radius: 6px;
padding: 8px;
padding: 5px;
}
.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");
appState.folders = result.folders;
const root = appState.folders.find((folder) => folder.parent_id === null);
@@ -125,8 +126,10 @@ export async function loadFolders() {
if (appState.selectedFolderId) {
$("#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?: string;
requestId?: string;
payload?: SceneModelMessagePayload;
message?: string;
};
@@ -42,6 +43,9 @@ type SceneModelMessage = {
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";
@@ -120,7 +124,7 @@ export function bindModelActions() {
});
}
export async function loadModels() {
export async function loadModels(options: { preserveCards?: boolean } = {}) {
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
if (!grid || !appState.selectedFolderId) return;
await loadDictionaries();
@@ -150,37 +154,85 @@ export async function loadModels() {
await loadModels();
}
});
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => {
const model = result.items.find((item) => item.id === Number(button.dataset.id));
if (model) {
button.addEventListener("click", async () => {
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);
}
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => {
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
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) => {
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
});
}
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) {
@@ -300,13 +352,27 @@ function requestSceneModelImport() {
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
}, resolveParentOrigin());
notify("已请求主程序导出当前选中模型");
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;
@@ -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) {
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() {
@@ -344,6 +425,16 @@ function resolveParentOrigin() {
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`;
}
@@ -446,7 +537,7 @@ async function openUploadModelDialog() {
if (result) {
invalidateDictionaries();
await loadModels();
await reloadFoldersAndModels();
}
}
@@ -464,40 +555,20 @@ export async function openProcessModelUploadDialog(input?: {
await loadDictionaries();
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
const result = await formDialog<boolean>({
title: "上传工艺模型",
width: 640,
height: 650,
title: `上传工艺模型-【${appState.selectedFolderName || "当前目录"}`,
width: 400,
height: 300,
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>
<label><span>模型名称</span><input id="processModelUploadName" name="name" value="${escapeHtml(initialName)}" autocomplete="off" /></label>
<label><span>品牌</span><input name="brandName" list="brandOptions" autocomplete="off" /></label>
<label><span>型</span><input name="typeName" list="typeOptions" autocomplete="off" /></label>
<label><span>型号</span><input name="model" autocomplete="off" /></label>
<label><span>价钱</span><input name="price" autocomplete="off" /></label>
<label><span>重量</span><input name="weight" autocomplete="off" /></label>
</div>
<label>
<span>工艺数据 JSON</span>
<textarea id="processOperationTreeJson" name="operationTree" placeholder='可以粘贴 {"OperationTree":"[...]"} 或 OperationTree[] 数组 JSON'>${escapeHtml(input?.operationTreeJson ?? "")}</textarea>
</label>
${dictionaryDatalistHtml()}
</form>
`,
@@ -527,7 +598,7 @@ export async function openProcessModelUploadDialog(input?: {
if (result) {
invalidateDictionaries();
await loadModels();
await reloadFoldersAndModels();
}
}
@@ -574,52 +645,52 @@ 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 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;
}
};
// 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;
}
}
// 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);
});
// fileInput.addEventListener("change", () => {
// const file = fileInput.files?.[0];
// if (file) selectFile(file);
// });
dropZone.addEventListener("click", (event) => {
if (event.target !== fileInput) fileInput.click();
});
// 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);
});
// 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) {
@@ -740,5 +811,5 @@ async function deleteModel(id: number) {
const confirmed = await confirmDialog("确认删除该模型?");
if (!confirmed) return;
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({
type: "DMT_MODEL_LIBRARY_IMPORT_SCENE",
payload: buildParentImportScenePayload(payload)
}, resolveParentOrigin());
}, resolveParentPostMessageOrigin());
} catch (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() {
try {
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", () => {
if (operations.length === 0) {
notify("当前模型暂无工艺数据");
return;
}
const operation = getSelectedOperation(operations);
updateStatus(operation);
if (!operation) {
@@ -634,7 +643,7 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
notifyError(`工艺播放数据解析失败:${operation.parseError}`);
return;
}
if (!operation.parsedCraftPlayData) {
if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
notify("当前工艺暂无播放数据");
return;
}
@@ -648,10 +657,14 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
}
function startPreviewPlayback(operation: PreviewOperation) {
if (!runtime?.context.modelObject || !operation.parsedCraftPlayData) {
if (!runtime?.context.modelObject) {
notify("模型还未加载完成");
return;
}
if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
notify("当前工艺暂无播放数据");
return;
}
if (previewPlayback.isPlaying && previewPlayback.operation === operation) {
return;
@@ -868,13 +881,7 @@ function resetPreviewPlaybackScene() {
}
function buildPreviewFrameTimes(frames: P_OPERATION["OPERATION"]["frames"]) {
let previousTime = 0;
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;
});
return frames.map((_, index) => index * DEFAULT_PREVIEW_FRAME_INTERVAL_MS);
}
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> {
const hasBody = options.body !== undefined && options.body !== null;
const response = await fetch(url, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...(hasBody && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
...authHeaders(),
...((options.headers as Record<string, string> | undefined) ?? {})
} as HeadersInit
@@ -20,4 +21,3 @@ export async function api<T>(url: string, options: RequestInit = {}): Promise<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 = {
detail: {
@@ -23,14 +23,27 @@ export function notifyError(error: unknown) {
export function confirmDialog(message: string, title = "确认") {
return new Promise<boolean>((resolve) => {
w2confirm({
msg: message,
let settled = false;
const settle = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
w2utils.confirm({
box: "body",
title,
yes: "确定",
no: "取消"
}, undefined, (action: string) => {
resolve(action === "yes" || action === "Yes");
});
text: message,
btn_yes: {
text: "确定"
},
btn_no: {
text: "取消"
}
})
.yes(() => settle(true))
.no(() => settle(false))
.close(() => settle(false));
});
}

View File

@@ -28,6 +28,29 @@ type W2Utils = {
title?: string;
text?: string;
}): 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 = {