initial commit

This commit is contained in:
zhangshun
2026-05-23 09:11:46 +08:00
commit 5d08480921
42 changed files with 10615 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
import { api } from "../../../services/api";
import type { DictionaryItem, DictionaryResponse } from "../../../types";
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
import { escapeHtml } from "../../../utils/format";
import { appState } from "../appState";
type DictionaryKind = "brands" | "types";
type DictionaryManageResult = {
changed: boolean;
};
const dictionaryLabels: Record<DictionaryKind, string> = {
brands: "品牌",
types: "类型"
};
let changed = false;
export async function openDictionaryManager(onChanged?: () => Promise<void> | void) {
changed = false;
await refreshDictionaries();
const result = await formDialog<DictionaryManageResult>({
title: "品牌 / 类型维护",
width: 680,
height: 520,
body: `
<div class="dictionary-manager">
${dictionaryPanelHtml("brands", "品牌")}
${dictionaryPanelHtml("types", "类型")}
</div>
`,
onOpen: bindDictionaryEvents,
onSubmit: () => ({ changed })
});
if (result?.changed) {
await onChanged?.();
}
}
async function refreshDictionaries() {
const result = await api<DictionaryResponse>("/api/dictionaries");
appState.brands = result.brands;
appState.types = result.types;
}
function dictionaryPanelHtml(kind: DictionaryKind, title: string) {
const items = kind === "brands" ? appState.brands : appState.types;
return `
<section class="dictionary-panel" data-kind="${kind}">
<header class="dictionary-panel-head">
<div class="dictionary-title">
<i aria-hidden="true"></i>
<strong>${title}</strong>
</div>
<span class="dictionary-count">${items.length} 项</span>
</header>
<div class="dictionary-editor">
<input data-role="name-input" data-kind="${kind}" placeholder="${title}名称" />
<button class="primary-btn dictionary-save-btn" type="button" data-action="save" data-kind="${kind}">新增</button>
<button class="ghost-btn dictionary-cancel-btn" type="button" data-action="cancel" data-kind="${kind}" hidden>取消</button>
</div>
<div class="dictionary-list-head">
<span>名称</span>
<span>操作</span>
</div>
<div class="dictionary-list">
${items.map((item) => dictionaryItemHtml(kind, item)).join("") || `<div class="dictionary-empty">暂无数据</div>`}
</div>
</section>
`;
}
function dictionaryItemHtml(kind: DictionaryKind, item: DictionaryItem) {
return `
<div class="dictionary-item" data-kind="${kind}" data-id="${item.id}" data-name="${escapeHtml(item.name)}">
<span class="dictionary-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>
<div class="dictionary-row-actions">
<button class="dictionary-edit-btn" type="button" data-action="edit" data-kind="${kind}" data-id="${item.id}">编辑</button>
<button class="danger-text-btn dictionary-delete-btn" type="button" data-action="delete" data-kind="${kind}" data-id="${item.id}">删除</button>
</div>
</div>
`;
}
function bindDictionaryEvents() {
document.querySelectorAll<HTMLButtonElement>(".dictionary-manager button").forEach((button) => {
button.addEventListener("click", async () => {
try {
const kind = button.dataset.kind as DictionaryKind;
const action = button.dataset.action;
const id = Number(button.dataset.id);
if (action === "save") await saveDictionaryItem(kind);
if (action === "cancel") resetDictionaryEditor(kind);
if (action === "edit") editDictionaryItem(kind, id);
if (action === "delete") await deleteDictionaryItem(kind, id);
} catch (error) {
notifyError(error);
}
});
});
}
async function saveDictionaryItem(kind: DictionaryKind) {
const input = getDictionaryInput(kind);
const name = input?.value.trim() ?? "";
if (!name) {
notify(`${dictionaryLabels[kind]}名称不能为空`);
return;
}
const editingId = input?.dataset.editingId;
await api(editingId ? `/api/dictionaries/${kind}/${editingId}` : `/api/dictionaries/${kind}`, {
method: editingId ? "PUT" : "POST",
body: JSON.stringify({ name })
});
changed = true;
notify(editingId ? "保存成功" : "新增成功");
await rerenderDictionaryManager();
}
function editDictionaryItem(kind: DictionaryKind, id: number) {
const current = findDictionaryItem(kind, id);
const input = getDictionaryInput(kind);
if (!current || !input) return;
input.value = current.name;
input.dataset.editingId = String(id);
const panel = document.querySelector<HTMLElement>(`.dictionary-panel[data-kind="${kind}"]`);
const saveButton = panel?.querySelector<HTMLButtonElement>('button[data-action="save"]');
const cancelButton = panel?.querySelector<HTMLButtonElement>('button[data-action="cancel"]');
if (saveButton) saveButton.textContent = "保存";
if (cancelButton) cancelButton.hidden = false;
input.focus();
}
function resetDictionaryEditor(kind: DictionaryKind) {
const input = getDictionaryInput(kind);
const panel = document.querySelector<HTMLElement>(`.dictionary-panel[data-kind="${kind}"]`);
const saveButton = panel?.querySelector<HTMLButtonElement>('button[data-action="save"]');
const cancelButton = panel?.querySelector<HTMLButtonElement>('button[data-action="cancel"]');
if (input) {
input.value = "";
delete input.dataset.editingId;
}
if (saveButton) saveButton.textContent = "新增";
if (cancelButton) cancelButton.hidden = true;
}
function getDictionaryInput(kind: DictionaryKind) {
return document.querySelector<HTMLInputElement>(`.dictionary-panel[data-kind="${kind}"] input[data-role="name-input"]`);
}
async function deleteDictionaryItem(kind: DictionaryKind, id: number) {
const current = findDictionaryItem(kind, id);
if (!current) return;
const confirmed = await confirmDialog(`删除 ${dictionaryLabels[kind]}${current.name}」后,已引用该项的模型会清空该字段,是否继续?`);
if (!confirmed) return;
await api(`/api/dictionaries/${kind}/${id}`, { method: "DELETE" });
changed = true;
notify("删除成功");
await rerenderDictionaryManager();
}
function findDictionaryItem(kind: DictionaryKind, id: number) {
const items = kind === "brands" ? appState.brands : appState.types;
return items.find((item) => item.id === id);
}
async function rerenderDictionaryManager() {
await refreshDictionaries();
const container = document.querySelector<HTMLDivElement>(".dictionary-manager");
if (!container) return;
container.innerHTML = `${dictionaryPanelHtml("brands", "品牌")}${dictionaryPanelHtml("types", "类型")}`;
bindDictionaryEvents();
}

View File

@@ -0,0 +1,125 @@
import $ from "jquery";
import "jstree";
import { api } from "../../../services/api";
import { getCurrentUser } from "../../../services/authState";
import type { FolderTreeResponse } from "../../../types";
import { confirmDialog, notify, notifyError, promptDialog } from "../../../ui/dialogs";
import { appState } from "../appState";
import { loadModels } from "./models";
type JsTreeNode = {
id: string;
text: string;
};
function selectFolder(node: JsTreeNode) {
appState.selectedFolderId = Number(node.id);
appState.selectedFolderName = node.text;
}
async function createFolder(parentId: number | null) {
const name = await promptDialog({ title: "新建目录", label: "目录名称" });
if (!name) return;
await api("/api/folders", {
method: "POST",
body: JSON.stringify({ parentId, name })
});
await loadFolders();
}
async function renameFolder(folderId: number | null) {
if (!folderId) return notify("请先选择目录");
const current = appState.folders.find((folder) => folder.id === folderId);
if (!current?.parent_id) return notify("根目录不能重命名");
const name = await promptDialog({ title: "重命名目录", label: "目录名称", value: current.name });
if (!name) return;
await api(`/api/folders/${folderId}`, {
method: "PUT",
body: JSON.stringify({ name })
});
await loadFolders();
}
async function deleteFolder(folderId: number | null) {
if (!folderId) return notify("请先选择目录");
const current = appState.folders.find((folder) => folder.id === folderId);
if (!current?.parent_id) return notify("根目录不能删除");
const confirmed = await confirmDialog("删除目录会删除目录下所有模型和子目录,是否继续?");
if (!confirmed) return;
await api(`/api/folders/${folderId}`, { method: "DELETE" });
appState.selectedFolderId = null;
await loadFolders();
}
async function runFolderAction(action: () => Promise<void>) {
try {
await action();
} catch (error) {
notifyError(error);
}
}
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);
appState.selectedFolderId ??= root?.id ?? null;
appState.selectedFolderName = appState.folders.find((folder) => folder.id === appState.selectedFolderId)?.name ?? "";
$("#folderTree").jstree("destroy");
$("#folderTree").jstree({
core: {
data: result.tree,
multiple: false
},
plugins: isAdmin ? ["contextmenu"] : [],
contextmenu: {
items(node: JsTreeNode) {
const folderId = Number(node.id);
const folder = appState.folders.find((item) => item.id === folderId);
const isRoot = folder?.parent_id === null;
return {
create: {
label: "新建目录",
icon: "tree-menu-icon tree-menu-icon-add",
action: () => runFolderAction(async () => {
selectFolder(node);
await createFolder(folderId);
})
},
rename: {
label: "重命名",
icon: "tree-menu-icon tree-menu-icon-edit",
_disabled: isRoot,
action: () => runFolderAction(async () => {
selectFolder(node);
await renameFolder(folderId);
})
},
remove: {
label: "删除",
icon: "tree-menu-icon tree-menu-icon-delete",
_disabled: isRoot,
action: () => runFolderAction(async () => {
selectFolder(node);
await deleteFolder(folderId);
})
}
};
}
}
}).on("select_node.jstree", async (_event: JQuery.Event, data: { node: { id: string; text: string } }) => {
appState.selectedFolderId = Number(data.node.id);
appState.selectedFolderName = data.node.text;
appState.page = 1;
await loadModels();
});
if (appState.selectedFolderId) {
$("#folderTree").on("ready.jstree", () => {
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId));
});
}
await loadModels();
}

View File

@@ -0,0 +1,359 @@
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;
};
export function bindModelActions() {
const isAdmin = getCurrentUser()?.role === "admin";
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(loadModels);
} 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;
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() {
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
if (!grid || !appState.selectedFolderId) return;
await loadDictionaries();
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();
}
document.querySelector("#modelCount")!.textContent = `${result.total} 个模型`;
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();
}
});
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 () => {
const { openModelPreview } = await import("./preview");
openModelPreview(model, loadModels);
});
}
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => {
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
button.addEventListener("click", () => {
notify("导入功能预留,后续接入当前模型的导入逻辑");
});
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
});
}
async function loadDictionaries() {
const result = await api<DictionaryResponse>("/api/dictionaries");
appState.brands = result.brands;
appState.types = result.types;
syncDictionarySelect("#brandFilter", result.brands, "全部品牌", appState.filters.brandId);
syncDictionarySelect("#typeFilter", result.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 isAdmin = getCurrentUser()?.role === "admin";
const prop = item.properties ?? {};
const thumb = item.thumbnail_url
? `<img src="${item.thumbnail_url}" alt="" />`
: `<div class="thumb-placeholder">GLB</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>
${isAdmin ? `<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>` : ""}
</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>
`;
}
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,
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")!);
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("file", state.file);
for (const key of ["model", "price", "weight"]) {
payload.set(`prop.${key}`, String(form.get(key) ?? ""));
}
await api("/api/models/upload", {
method: "POST",
body: payload
});
return true;
}
});
if (result) {
await loadModels();
}
}
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);
});
}
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,
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
}
})
});
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 loadModels();
}

View File

@@ -0,0 +1,248 @@
import * as THREE from "three";
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 { 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;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
animationId: number;
resizeObserver: ResizeObserver;
};
let runtime: PreviewRuntime | null = null;
export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Promise<void> | void) {
disposePreview();
const isAdmin = getCurrentUser()?.role === "admin";
const url = model.file_url;
const popup = w2popup.open({
title: `模型预览 - ${escapeHtml(model.name)}`,
width: 860,
height: 620,
modal: true,
body: `
<div class="preview-shell">
<aside class="preview-process-panel">
<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>
</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>
</ul>
<div class="preview-process-actions">
<button id="previewPlayBtn" type="button">播放</button>
<button id="previewPauseBtn" type="button">暂停</button>
</div>
</div>
<div class="preview-scene-section">
<div class="preview-process-header">
<strong>导入场景</strong>
<span>占位</span>
</div>
<div class="preview-scene-body">
${isAdmin ? `
<button id="previewCaptureThumbBtn" type="button">截缩略图</button>
<label class="preview-switch">
<input id="previewTransparentThumb" type="checkbox" />
<span>透明背景截图</span>
</label>
` : ""}
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
<label class="preview-switch">
<input id="previewUseBasePoint" type="checkbox" />
<span>启用基点导入</span>
</label>
<div class="preview-basepoint-grid">
<label><span>X</span><input name="baseX" type="number" value="0" step="0.001" /></label>
<label><span>Y</span><input name="baseY" type="number" value="0" step="0.001" /></label>
<label><span>Z</span><input name="baseZ" type="number" value="0" step="0.001" /></label>
<label><span>RX</span><input name="baseRx" type="number" value="0" step="0.001" /></label>
<label><span>RY</span><input name="baseRy" type="number" value="0" step="0.001" /></label>
<label><span>RZ</span><input name="baseRz" type="number" value="0" step="0.001" /></label>
</div>
</div>
</div>
</aside>
<div class="preview-canvas-panel">
<div id="modelPreviewViewport" class="preview-viewport">
<div class="preview-loading">模型加载中...</div>
</div>
</div>
</div>
`,
actions: {
() {
disposePreview();
w2popup.close();
}
}
});
popup.self
.on("open:after", () => {
bindProcessPlaceholder();
if (isAdmin) bindThumbnailCapture(model.id, onThumbnailSaved);
initPreview(url).catch((error) => notifyError(error));
})
.on("close:after", () => disposePreview());
}
function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
try {
if (!runtime) {
throw new Error("模型还未加载完成");
}
const transparent = document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
runtime.controls.update();
const oldBackground = runtime.scene.background;
const oldClearAlpha = runtime.renderer.getClearAlpha();
if (transparent) {
runtime.scene.background = null;
runtime.renderer.setClearColor(0x000000, 0);
}
runtime.renderer.render(runtime.scene, runtime.camera);
const thumbnail = runtime.renderer.domElement.toDataURL("image/png");
if (transparent) {
runtime.scene.background = oldBackground;
runtime.renderer.setClearAlpha(oldClearAlpha);
runtime.renderer.render(runtime.scene, runtime.camera);
}
await api(`/api/models/${modelId}/thumbnail`, {
method: "PUT",
body: JSON.stringify({ thumbnail })
});
await onThumbnailSaved?.();
notify("缩略图已保存");
} catch (error) {
notifyError(error);
}
});
}
function bindProcessPlaceholder() {
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");
});
});
}
async function initPreview(url: string) {
const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
if (!viewport) return;
viewport.innerHTML = "";
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf4f7f9);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
camera.up.set(0, 0, 1);
camera.position.set(3, -4, 2.5);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
viewport.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0xb7c3cc, 1.2));
const keyLight = new THREE.DirectionalLight(0xffffff, 2);
keyLight.position.set(4, -5, 6);
scene.add(keyLight);
const loader = new GLTFLoader();
const gltf = await loader.loadAsync(url);
const object = gltf.scene;
scene.add(object);
fitCameraToObject(camera, controls, object);
const resize = () => {
const width = Math.max(viewport.clientWidth, 1);
const height = Math.max(viewport.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(viewport);
resize();
const animate = () => {
controls.update();
renderer.render(scene, camera);
if (runtime) {
runtime.animationId = requestAnimationFrame(animate);
}
};
runtime = {
renderer,
scene,
camera,
controls,
animationId: requestAnimationFrame(animate),
resizeObserver
};
}
function fitCameraToObject(camera: THREE.PerspectiveCamera, controls: OrbitControls, object: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxSize = Math.max(size.x, size.y, size.z) || 1;
const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));
camera.up.set(0, 0, 1);
camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
camera.near = Math.max(distance / 100, 0.01);
camera.far = distance * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
controls.minDistance = distance / 8;
controls.maxDistance = distance * 8;
controls.update();
}
function disposePreview() {
if (!runtime) return;
cancelAnimationFrame(runtime.animationId);
runtime.resizeObserver.disconnect();
runtime.controls.dispose();
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
runtime = null;
}

View File

@@ -0,0 +1,266 @@
import { api } from "../../../services/api";
import { getCurrentUser } from "../../../services/authState";
import type { ManagedUser, UserListResponse, UserRole } from "../../../types";
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
import { escapeHtml } from "../../../utils/format";
type UserFormMode = "create" | "edit";
let users: ManagedUser[] = [];
let editingUserId: number | null = null;
export async function openUserManager() {
await refreshUsers();
editingUserId = null;
await formDialog<boolean>({
title: "人员权限管理",
width: 980,
height: 560,
body: `
<div class="user-manager">
<header class="user-manager-head">
<div>
<strong>账号列表</strong>
<span>维护人员角色、启停状态和授权到期时间</span>
</div>
<button id="addUserBtn" class="primary-btn" type="button">新增人员</button>
</header>
<div class="user-manager-body">
<div class="user-table">
<div class="user-table-head">
<span>用户</span>
<span>角色</span>
<span>状态</span>
<span>授权到期</span>
<span>更新时间</span>
<span>操作</span>
</div>
<div id="userTableBody" class="user-table-body">
${userRowsHtml()}
</div>
</div>
<aside id="userEditorHost" class="user-editor-panel">
${userEditorEmptyHtml()}
</aside>
</div>
</div>
`,
onOpen: bindUserManagerEvents,
onSubmit: () => true
});
}
async function refreshUsers() {
const result = await api<UserListResponse>("/api/users");
users = result.users;
}
function userRowsHtml() {
return users.map(userRowHtml).join("") || `<div class="user-empty">暂无人员</div>`;
}
function userRowHtml(user: ManagedUser) {
const currentUser = getCurrentUser();
const locked = currentUser?.id === user.id;
const expired = isExpired(user.expires_at);
const statusClass = user.enabled ? (expired ? "is-expired" : "is-enabled") : "is-disabled";
const statusText = user.enabled ? (expired ? "已过期" : "启用") : "禁用";
return `
<div class="user-row" data-id="${user.id}">
<span class="user-name">
<strong>${escapeHtml(user.username)}</strong>
${locked ? `<em>当前账号</em>` : ""}
</span>
<span><i class="role-badge ${user.role === "admin" ? "is-admin" : ""}">${roleText(user.role)}</i></span>
<span><i class="status-badge ${statusClass}">${statusText}</i></span>
<span>${formatDate(user.expires_at) || "长期有效"}</span>
<span>${formatDate(user.updated_at)}</span>
<span class="user-row-actions">
<button type="button" data-action="edit" data-id="${user.id}">编辑</button>
<button class="danger-text-btn" type="button" data-action="delete" data-id="${user.id}" ${locked ? "disabled" : ""}>删除</button>
</span>
</div>
`;
}
function bindUserManagerEvents() {
document.querySelector("#addUserBtn")?.addEventListener("click", () => {
renderUserEditor("create");
});
bindUserRowEvents();
bindUserEditorEvents();
}
function bindUserRowEvents() {
document.querySelectorAll<HTMLButtonElement>(".user-table-body button").forEach((button) => {
button.addEventListener("click", async () => {
try {
const id = Number(button.dataset.id);
if (button.dataset.action === "edit") renderUserEditor("edit", id);
if (button.dataset.action === "delete") await deleteUser(id);
} catch (error) {
notifyError(error);
}
});
});
}
function renderUserEditor(mode: UserFormMode, id?: number) {
editingUserId = mode === "edit" ? id ?? null : null;
const user = id ? users.find((item) => item.id === id) : undefined;
const isCreate = mode === "create";
const host = document.querySelector<HTMLElement>("#userEditorHost");
if (!host) return;
host.innerHTML = `
<div class="user-editor-title">
<strong>${isCreate ? "新增人员" : "编辑权限"}</strong>
<span>${isCreate ? "创建账号并设置初始权限" : "调整角色、状态和授权期限"}</span>
</div>
<form id="userEditorForm" class="popup-form user-editor-form" data-mode="${mode}">
${isCreate ? `
<label>
<span>用户名</span>
<input name="username" autocomplete="off" />
</label>
` : `
<label>
<span>用户名</span>
<input value="${escapeHtml(user?.username ?? "")}" disabled />
</label>
`}
<div class="popup-form-grid">
<label>
<span>角色</span>
<select name="role">
<option value="user" ${user?.role === "user" ? "selected" : ""}>普通用户</option>
<option value="admin" ${user?.role === "admin" ? "selected" : ""}>管理员</option>
</select>
</label>
<label>
<span>状态</span>
<select name="enabled">
<option value="true" ${user?.enabled === false ? "" : "selected"}>启用</option>
<option value="false" ${user?.enabled === false ? "selected" : ""}>禁用</option>
</select>
</label>
</div>
<label>
<span>授权到期</span>
<input name="expiresAt" type="date" value="${dateInputValue(user?.expires_at)}" />
</label>
<label>
<span>${isCreate ? "初始密码" : "重置密码"}</span>
<input name="password" type="password" autocomplete="new-password" placeholder="${isCreate ? "至少 6 位" : "不填写则保持原密码"}" />
</label>
<div class="user-editor-actions">
<button id="cancelUserEditBtn" class="ghost-btn" type="button">取消</button>
<button id="saveUserBtn" class="primary-btn" type="button">${isCreate ? "新增" : "保存"}</button>
</div>
</form>
`;
bindUserEditorEvents();
}
function userEditorEmptyHtml() {
return `
<div class="user-editor-empty">
<strong>选择人员</strong>
<span>点击新增或编辑后,在这里维护账号权限。</span>
</div>
`;
}
function bindUserEditorEvents() {
document.querySelector("#saveUserBtn")?.addEventListener("click", async () => {
try {
await saveUserEditor();
} catch (error) {
notifyError(error);
}
});
document.querySelector("#cancelUserEditBtn")?.addEventListener("click", () => {
editingUserId = null;
const host = document.querySelector<HTMLElement>("#userEditorHost");
if (host) host.innerHTML = userEditorEmptyHtml();
});
}
async function saveUserEditor() {
const formElement = document.querySelector<HTMLFormElement>("#userEditorForm");
if (!formElement) return;
const form = new FormData(formElement);
const isCreate = formElement.dataset.mode === "create";
const password = String(form.get("password") ?? "").trim();
const result = {
username: String(form.get("username") ?? "").trim(),
password,
role: String(form.get("role") ?? "user") as UserRole,
enabled: String(form.get("enabled") ?? "true") === "true",
expiresAt: String(form.get("expiresAt") ?? "") || null
};
if (isCreate && !result.username) throw new Error("用户名不能为空");
if (isCreate && result.password.length < 6) throw new Error("初始密码至少 6 位");
if (!isCreate && result.password && result.password.length < 6) throw new Error("重置密码至少 6 位");
if (isCreate) {
await api("/api/users", {
method: "POST",
body: JSON.stringify(result)
});
notify("新增成功");
} else {
if (!editingUserId) throw new Error("请选择要编辑的用户");
await api(`/api/users/${editingUserId}`, {
method: "PUT",
body: JSON.stringify({
role: result.role,
enabled: result.enabled,
expiresAt: result.expiresAt,
...(result.password ? { password: result.password } : {})
})
});
notify("保存成功");
}
await rerenderUserRows();
if (isCreate) {
const host = document.querySelector<HTMLElement>("#userEditorHost");
if (host) host.innerHTML = userEditorEmptyHtml();
}
}
async function deleteUser(id: number) {
const user = users.find((item) => item.id === id);
if (!user) return;
const confirmed = await confirmDialog(`确认删除用户「${user.username}」?`);
if (!confirmed) return;
await api(`/api/users/${id}`, { method: "DELETE" });
notify("删除成功");
await rerenderUserRows();
}
async function rerenderUserRows() {
await refreshUsers();
const body = document.querySelector<HTMLDivElement>("#userTableBody");
if (!body) return;
body.innerHTML = userRowsHtml();
bindUserRowEvents();
if (editingUserId) {
renderUserEditor("edit", editingUserId);
}
}
function roleText(role: UserRole) {
return role === "admin" ? "管理员" : "普通用户";
}
function isExpired(value: string | null) {
return Boolean(value && new Date(value).getTime() < Date.now());
}
function formatDate(value: string | null) {
if (!value) return "";
return value.slice(0, 10);
}
function dateInputValue(value: string | null | undefined) {
return value ? value.slice(0, 10) : "";
}