initial commit
This commit is contained in:
359
web/src/pages/app/modules/models.ts
Normal file
359
web/src/pages/app/modules/models.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user