完善模型库权限与工艺数据解析
This commit is contained in:
@@ -134,6 +134,12 @@
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.preview-process-empty {
|
||||
padding: 28px 8px;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-process-tree button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
@@ -155,6 +161,14 @@
|
||||
border-top: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-process-status {
|
||||
padding: 6px 8px 8px;
|
||||
border-top: 1px solid #eef2f5;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview-scene-section {
|
||||
border-top: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function renderApp() {
|
||||
<div class="content-actions">
|
||||
${isAdmin ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
|
||||
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
|
||||
${isAdmin ? `<button id="addModelBtn" class="primary-btn" type="button">增加模型</button>` : ""}
|
||||
<button id="addModelBtn" class="primary-btn" type="button" hidden>增加模型</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-toolbar">
|
||||
|
||||
@@ -12,6 +12,10 @@ type JsTreeNode = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
function isRealFolderId(id: string) {
|
||||
return /^\d+$/.test(id);
|
||||
}
|
||||
|
||||
function selectFolder(node: JsTreeNode) {
|
||||
appState.selectedFolderId = Number(node.id);
|
||||
appState.selectedFolderName = appState.folders.find((folder) => folder.id === Number(node.id))?.name ?? node.text;
|
||||
@@ -60,7 +64,6 @@ async function runFolderAction(action: () => Promise<void>) {
|
||||
}
|
||||
|
||||
export async function loadFolders() {
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
const result = await api<FolderTreeResponse>("/api/folders");
|
||||
appState.folders = result.folders;
|
||||
const root = appState.folders.find((folder) => folder.parent_id === null);
|
||||
@@ -73,16 +76,19 @@ export async function loadFolders() {
|
||||
data: result.tree,
|
||||
multiple: false
|
||||
},
|
||||
plugins: isAdmin ? ["contextmenu"] : [],
|
||||
plugins: ["contextmenu"],
|
||||
contextmenu: {
|
||||
items(node: JsTreeNode) {
|
||||
if (!isRealFolderId(node.id)) return {};
|
||||
const folderId = Number(node.id);
|
||||
const folder = appState.folders.find((item) => item.id === folderId);
|
||||
const isRoot = folder?.parent_id === null;
|
||||
const canWrite = Boolean(folder?.permissions.write);
|
||||
const isSystem = Boolean(folder?.is_system);
|
||||
return {
|
||||
create: {
|
||||
label: "新建目录",
|
||||
icon: "tree-menu-icon tree-menu-icon-add",
|
||||
_disabled: !canWrite,
|
||||
action: () => runFolderAction(async () => {
|
||||
selectFolder(node);
|
||||
await createFolder(folderId);
|
||||
@@ -91,7 +97,7 @@ export async function loadFolders() {
|
||||
rename: {
|
||||
label: "重命名",
|
||||
icon: "tree-menu-icon tree-menu-icon-edit",
|
||||
_disabled: isRoot,
|
||||
_disabled: !canWrite || isSystem,
|
||||
action: () => runFolderAction(async () => {
|
||||
selectFolder(node);
|
||||
await renameFolder(folderId);
|
||||
@@ -100,7 +106,7 @@ export async function loadFolders() {
|
||||
remove: {
|
||||
label: "删除",
|
||||
icon: "tree-menu-icon tree-menu-icon-delete",
|
||||
_disabled: isRoot,
|
||||
_disabled: !canWrite || isSystem,
|
||||
action: () => runFolderAction(async () => {
|
||||
selectFolder(node);
|
||||
await deleteFolder(folderId);
|
||||
@@ -110,6 +116,7 @@ export async function loadFolders() {
|
||||
}
|
||||
}
|
||||
}).on("select_node.jstree", async (_event: JQuery.Event, data: { node: { id: string; text: string } }) => {
|
||||
if (!isRealFolderId(data.node.id)) return;
|
||||
appState.selectedFolderId = Number(data.node.id);
|
||||
appState.selectedFolderName = appState.folders.find((folder) => folder.id === appState.selectedFolderId)?.name ?? data.node.text;
|
||||
appState.page = 1;
|
||||
|
||||
@@ -10,6 +10,8 @@ type UploadFormState = {
|
||||
file: File | null;
|
||||
};
|
||||
|
||||
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||
|
||||
export function bindModelActions() {
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
if (isAdmin) {
|
||||
@@ -31,15 +33,16 @@ export function bindModelActions() {
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await openUploadModelDialog();
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await openUploadModelDialog();
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector<HTMLSelectElement>("#brandFilter")!.addEventListener("change", async (event) => {
|
||||
appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value;
|
||||
appState.page = 1;
|
||||
@@ -77,6 +80,7 @@ export async function loadModels() {
|
||||
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),
|
||||
@@ -144,7 +148,7 @@ function syncDictionarySelect(selector: string, items: { id: number; name: strin
|
||||
}
|
||||
|
||||
function renderModelCard(item: ModelItem) {
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
const canWrite = item.permissions.write;
|
||||
const prop = item.properties ?? {};
|
||||
const thumb = item.thumbnail_url
|
||||
? `<img src="${item.thumbnail_url}" alt="" />`
|
||||
@@ -158,9 +162,9 @@ function renderModelCard(item: ModelItem) {
|
||||
${thumb}
|
||||
<div class="thumb-actions">
|
||||
<button data-action="preview" data-id="${item.id}">预览</button>
|
||||
${isAdmin ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
||||
${canWrite ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
||||
<button data-action="import" data-id="${item.id}">导入</button>
|
||||
${isAdmin ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
||||
${canWrite ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
@@ -174,6 +178,13 @@ function renderModelCard(item: ModelItem) {
|
||||
`;
|
||||
}
|
||||
|
||||
function syncSelectedFolderActions() {
|
||||
const currentFolder = appState.folders.find((folder) => folder.id === appState.selectedFolderId);
|
||||
const canWrite = Boolean(currentFolder?.permissions.write);
|
||||
const addModelBtn = document.querySelector<HTMLButtonElement>("#addModelBtn");
|
||||
if (addModelBtn) addModelBtn.hidden = !canWrite;
|
||||
}
|
||||
|
||||
async function openUploadModelDialog() {
|
||||
if (!appState.selectedFolderId) {
|
||||
notify("请先选择目录");
|
||||
@@ -226,6 +237,7 @@ async function openUploadModelDialog() {
|
||||
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) ?? ""));
|
||||
|
||||
@@ -3,10 +3,11 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { w2popup } from "w2ui";
|
||||
import type { ModelItem } from "../../../types";
|
||||
import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree";
|
||||
import { P_OPERATION } from "../../../types/OPERATION_BaseClass";
|
||||
import { escapeHtml, formatBytes } from "../../../utils/format";
|
||||
import { notify, notifyError } from "../../../ui/dialogs";
|
||||
import { api } from "../../../services/api";
|
||||
import { getCurrentUser } from "../../../services/authState";
|
||||
|
||||
type PreviewRuntime = {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
@@ -17,14 +18,21 @@ type PreviewRuntime = {
|
||||
resizeObserver: ResizeObserver;
|
||||
};
|
||||
|
||||
type PreviewOperation = OperationTree & {
|
||||
parsedCraftPlayData: P_OPERATION | null;
|
||||
frameCount: number;
|
||||
parseError: string | null;
|
||||
};
|
||||
|
||||
let runtime: PreviewRuntime | null = null;
|
||||
|
||||
export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Promise<void> | void) {
|
||||
disposePreview();
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
const canWrite = model.permissions.write;
|
||||
const url = model.file_url;
|
||||
const operations = parseModelOperations(model.operation_tree);
|
||||
const popup = w2popup.open({
|
||||
title: `模型预览 - ${escapeHtml(model.name)}`,
|
||||
title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`,
|
||||
width: 860,
|
||||
height: 620,
|
||||
modal: true,
|
||||
@@ -34,35 +42,22 @@ export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Prom
|
||||
<div class="preview-process-section">
|
||||
<div class="preview-process-header">
|
||||
<strong>工艺播放</strong>
|
||||
<span>占位</span>
|
||||
</div>
|
||||
<div class="preview-model-meta">
|
||||
<span>模型大小</span>
|
||||
<strong>${formatBytes(model.file_size)}</strong>
|
||||
<span>${operations.length} 项</span>
|
||||
</div>
|
||||
<ul class="preview-process-tree">
|
||||
<li class="is-active">
|
||||
<button type="button" data-process-id="process-1">工艺 1</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" data-process-id="process-2">工艺 2</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" data-process-id="process-3">工艺 3</button>
|
||||
</li>
|
||||
${operationListHtml(operations)}
|
||||
</ul>
|
||||
<div class="preview-process-actions">
|
||||
<button id="previewPlayBtn" type="button">播放</button>
|
||||
<button id="previewPauseBtn" type="button">暂停</button>
|
||||
</div>
|
||||
<div id="previewProcessStatus" class="preview-process-status">
|
||||
${operationStatusHtml(operations[0] ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-scene-section">
|
||||
<div class="preview-process-header">
|
||||
<strong>导入场景</strong>
|
||||
<span>占位</span>
|
||||
</div>
|
||||
<div class="preview-scene-body">
|
||||
${isAdmin ? `
|
||||
${canWrite ? `
|
||||
<button id="previewCaptureThumbBtn" type="button">截缩略图</button>
|
||||
<label class="preview-switch">
|
||||
<input id="previewTransparentThumb" type="checkbox" />
|
||||
@@ -102,13 +97,81 @@ export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Prom
|
||||
|
||||
popup.self
|
||||
.on("open:after", () => {
|
||||
bindProcessPlaceholder();
|
||||
if (isAdmin) bindThumbnailCapture(model.id, onThumbnailSaved);
|
||||
bindProcessPlaceholder(operations);
|
||||
if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
|
||||
initPreview(url).catch((error) => notifyError(error));
|
||||
})
|
||||
.on("close:after", () => disposePreview());
|
||||
}
|
||||
|
||||
function parseModelOperations(value: string): PreviewOperation[] {
|
||||
try {
|
||||
const dbValue = JSON.parse(value || "{}") as Partial<OperationTreeDB>;
|
||||
const list = JSON.parse(dbValue.OperationTree || "[]") as unknown;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.filter((item): item is OperationTree => Boolean(item && typeof item === "object"))
|
||||
.map((operation) => {
|
||||
const parsed = parseCraftPlayData(operation.CraftPlayData);
|
||||
return {
|
||||
...operation,
|
||||
parsedCraftPlayData: parsed.data,
|
||||
frameCount: parsed.data?.OPERATION.frames.length ?? 0,
|
||||
parseError: parsed.error
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseCraftPlayData(value?: string | null): { data: P_OPERATION | null; error: string | null } {
|
||||
const text = value?.trim();
|
||||
if (!text) return { data: null, error: null };
|
||||
try {
|
||||
const jsonData = parseJsonText(text);
|
||||
return { data: P_OPERATION.fromjson(jsonData), error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "工艺播放数据解析失败"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonText(text: string): unknown {
|
||||
let value: unknown = text;
|
||||
for (let index = 0; index < 2 && typeof value === "string"; index += 1) {
|
||||
value = JSON.parse(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function operationListHtml(operations: PreviewOperation[]) {
|
||||
if (operations.length === 0) {
|
||||
return `<li class="preview-process-empty">暂无工艺数据</li>`;
|
||||
}
|
||||
return operations.map((operation, index) => {
|
||||
const name = operation.CraftName?.trim() || `工艺 ${index + 1}`;
|
||||
const id = operation.ID || operation.CraftCode || String(index + 1);
|
||||
const title = operation.parseError
|
||||
? `${name} / 播放数据解析失败`
|
||||
: `${name} / ${operation.frameCount} 帧`;
|
||||
return `
|
||||
<li class="${index === 0 ? "is-active" : ""}">
|
||||
<button type="button" data-process-id="${escapeHtml(String(id))}" data-process-index="${index}" title="${escapeHtml(title)}">${escapeHtml(name)}</button>
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function operationStatusHtml(operation: PreviewOperation | null) {
|
||||
if (!operation) return "未选择工艺";
|
||||
if (operation.parseError) return `播放数据解析失败:${escapeHtml(operation.parseError)}`;
|
||||
if (!operation.parsedCraftPlayData) return "当前工艺暂无播放数据";
|
||||
return `已读取 ${operation.frameCount} 帧播放数据`;
|
||||
}
|
||||
|
||||
function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
|
||||
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
@@ -142,13 +205,51 @@ function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<
|
||||
});
|
||||
}
|
||||
|
||||
function bindProcessPlaceholder() {
|
||||
function bindProcessPlaceholder(operations: PreviewOperation[]) {
|
||||
const getSelectedOperation = () => {
|
||||
const selectedButton = document.querySelector<HTMLButtonElement>(".preview-process-tree li.is-active button");
|
||||
const index = Number(selectedButton?.dataset.processIndex ?? 0);
|
||||
return operations[index] ?? null;
|
||||
};
|
||||
|
||||
const updateStatus = (operation: PreviewOperation | null) => {
|
||||
const status = document.querySelector<HTMLDivElement>("#previewProcessStatus");
|
||||
if (status) {
|
||||
status.innerHTML = operationStatusHtml(operation);
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".preview-process-tree button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
|
||||
button.closest("li")?.classList.add("is-active");
|
||||
const index = Number(button.dataset.processIndex ?? 0);
|
||||
updateStatus(operations[index] ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
|
||||
const operation = getSelectedOperation();
|
||||
updateStatus(operation);
|
||||
if (!operation) {
|
||||
notify("请先选择工艺");
|
||||
return;
|
||||
}
|
||||
if (operation.parseError) {
|
||||
notifyError(`工艺播放数据解析失败:${operation.parseError}`);
|
||||
return;
|
||||
}
|
||||
if (!operation.parsedCraftPlayData) {
|
||||
notify("当前工艺暂无播放数据");
|
||||
return;
|
||||
}
|
||||
notify(`${operation.CraftName || "当前工艺"} 已读取 ${operation.frameCount} 帧,播放逻辑待接入`);
|
||||
});
|
||||
|
||||
document.querySelector<HTMLButtonElement>("#previewPauseBtn")?.addEventListener("click", () => {
|
||||
updateStatus(getSelectedOperation());
|
||||
notify("暂停逻辑待接入");
|
||||
});
|
||||
}
|
||||
|
||||
async function initPreview(url: string) {
|
||||
|
||||
Reference in New Issue
Block a user