initial commit
This commit is contained in:
84
web/src/components/pagination.ts
Normal file
84
web/src/components/pagination.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type PaginationOptions = {
|
||||
container: HTMLElement;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageSizes?: number[];
|
||||
onChange: (page: number, pageSize: number) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function clampPage(page: number, totalPages: number) {
|
||||
return Math.min(Math.max(page, 1), totalPages);
|
||||
}
|
||||
|
||||
function pageItems(page: number, totalPages: number) {
|
||||
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]);
|
||||
if (page <= 3) {
|
||||
pages.add(2);
|
||||
pages.add(3);
|
||||
}
|
||||
if (page >= totalPages - 2) {
|
||||
pages.add(totalPages - 1);
|
||||
pages.add(totalPages - 2);
|
||||
}
|
||||
const sorted = [...pages].filter((item) => item >= 1 && item <= totalPages).sort((a, b) => a - b);
|
||||
const result: Array<number | "..."> = [];
|
||||
for (const item of sorted) {
|
||||
const previous = result[result.length - 1];
|
||||
if (typeof previous === "number" && item - previous > 1) {
|
||||
result.push("...");
|
||||
}
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderPagination(options: PaginationOptions) {
|
||||
const totalPages = Math.max(Math.ceil(options.total / options.pageSize), 1);
|
||||
const page = clampPage(options.page, totalPages);
|
||||
const pageSizes = options.pageSizes ?? [12, 24, 48, 96];
|
||||
const start = options.total === 0 ? 0 : (page - 1) * options.pageSize + 1;
|
||||
const end = Math.min(page * options.pageSize, options.total);
|
||||
|
||||
options.container.innerHTML = `
|
||||
<div class="pagination-total">共 ${options.total} 条</div>
|
||||
<div class="pagination-range">${start}-${end}</div>
|
||||
<select class="pagination-size" aria-label="每页条数">
|
||||
${pageSizes.map((size) => `<option value="${size}" ${size === options.pageSize ? "selected" : ""}>${size} 条/页</option>`).join("")}
|
||||
</select>
|
||||
<button class="pagination-btn" type="button" data-page="${page - 1}" ${page <= 1 ? "disabled" : ""}>上一页</button>
|
||||
<div class="pagination-pages">
|
||||
${pageItems(page, totalPages).map((item) => item === "..."
|
||||
? `<span class="pagination-ellipsis">...</span>`
|
||||
: `<button class="pagination-btn pagination-page ${item === page ? "is-active" : ""}" type="button" data-page="${item}">${item}</button>`).join("")}
|
||||
</div>
|
||||
<button class="pagination-btn" type="button" data-page="${page + 1}" ${page >= totalPages ? "disabled" : ""}>下一页</button>
|
||||
<label class="pagination-jumper">
|
||||
<span>前往</span>
|
||||
<input type="number" min="1" max="${totalPages}" value="${page}" />
|
||||
<span>页</span>
|
||||
</label>
|
||||
`;
|
||||
|
||||
options.container.querySelector<HTMLSelectElement>(".pagination-size")?.addEventListener("change", async (event) => {
|
||||
const nextPageSize = Number((event.currentTarget as HTMLSelectElement).value);
|
||||
await options.onChange(1, nextPageSize);
|
||||
});
|
||||
|
||||
options.container.querySelectorAll<HTMLButtonElement>("[data-page]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const nextPage = clampPage(Number(button.dataset.page), totalPages);
|
||||
if (nextPage === page) return;
|
||||
await options.onChange(nextPage, options.pageSize);
|
||||
});
|
||||
});
|
||||
|
||||
const jumper = options.container.querySelector<HTMLInputElement>(".pagination-jumper input");
|
||||
jumper?.addEventListener("keydown", async (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
await options.onChange(clampPage(Number(jumper.value || 1), totalPages), options.pageSize);
|
||||
});
|
||||
jumper?.addEventListener("change", async () => {
|
||||
await options.onChange(clampPage(Number(jumper.value || 1), totalPages), options.pageSize);
|
||||
});
|
||||
}
|
||||
25
web/src/main.ts
Normal file
25
web/src/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import "jstree/dist/themes/default/style.min.css";
|
||||
import "w2ui/w2ui-2.0.min.css";
|
||||
import "./styles/base.css";
|
||||
import { api } from "./services/api";
|
||||
import { getToken, setCurrentUser, clearSession } from "./services/authState";
|
||||
import type { AuthUser } from "./types";
|
||||
import { renderLogin } from "./pages/login/login";
|
||||
import { renderApp } from "./pages/app/layout";
|
||||
|
||||
async function bootstrap() {
|
||||
if (!getToken()) {
|
||||
renderLogin(renderApp);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api<{ user: AuthUser }>("/api/auth/me");
|
||||
setCurrentUser(result.user);
|
||||
await renderApp();
|
||||
} catch {
|
||||
clearSession();
|
||||
renderLogin(renderApp);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
340
web/src/pages/app/app.css
Normal file
340
web/src/pages/app/app.css
Normal file
@@ -0,0 +1,340 @@
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 42px 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.topbar strong {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.topbar span {
|
||||
color: #5d6d7e;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree-pane {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
#folderTree {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.content-pane {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.content-toolbar h2 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content-toolbar p {
|
||||
margin: 0;
|
||||
color: #647586;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 160px) minmax(120px, 160px) minmax(200px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafb;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.filter-toolbar label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.filter-toolbar span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-toolbar select {
|
||||
width: 100%;
|
||||
border: 1px solid #c9d3dd;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.model-grid {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
linear-gradient(45deg, #edf2f5 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #edf2f5 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #edf2f5 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #edf2f5 75%),
|
||||
#f8fafb;
|
||||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||||
background-size: 12px 12px;
|
||||
border: 1px solid #d3dde5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.thumb-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
background: rgba(22, 33, 44, 0.68);
|
||||
opacity: 0;
|
||||
transform: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.14s ease;
|
||||
}
|
||||
|
||||
.thumb:hover .thumb-actions,
|
||||
.thumb:focus-within .thumb-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.thumb-actions button {
|
||||
width: min(86px, 80%);
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
color: #456171;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.model-info strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid #e3e9ee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
dl div:nth-child(odd) {
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
dl div:nth-child(even) {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #6b7b8b;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
min-height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
color: #405469;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-total,
|
||||
.pagination-range {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-size {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 4px 26px 4px 8px;
|
||||
}
|
||||
|
||||
.pagination-pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
border-color: transparent;
|
||||
background: #f6f9fb;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled),
|
||||
.pagination-page.is-active {
|
||||
border-color: #1f6f8b;
|
||||
background: #eaf4f7;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.pagination-page.is-active {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
min-width: 20px;
|
||||
color: #8b99a7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-jumper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-jumper input {
|
||||
width: 46px;
|
||||
min-height: 28px;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: #667789;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.content-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.keyword-filter {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
16
web/src/pages/app/appState.ts
Normal file
16
web/src/pages/app/appState.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { DictionaryItem, Folder } from "../../types";
|
||||
|
||||
export const appState = {
|
||||
selectedFolderId: null as number | null,
|
||||
selectedFolderName: "",
|
||||
folders: [] as Folder[],
|
||||
brands: [] as DictionaryItem[],
|
||||
types: [] as DictionaryItem[],
|
||||
filters: {
|
||||
brandId: "",
|
||||
typeId: "",
|
||||
keyword: ""
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 12
|
||||
};
|
||||
687
web/src/pages/app/dialogs.css
Normal file
687
web/src/pages/app/dialogs.css
Normal file
@@ -0,0 +1,687 @@
|
||||
.popup-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.popup-form label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.popup-form label span {
|
||||
color: #405469;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.popup-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-form-grid label:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
position: relative;
|
||||
min-height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #9fb1c0;
|
||||
border-radius: 8px;
|
||||
background: #f6f9fb;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone.is-dragover {
|
||||
border-color: #1f6f8b;
|
||||
background: #eaf4f7;
|
||||
}
|
||||
|
||||
.drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone strong {
|
||||
color: #1f6f8b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drop-zone em {
|
||||
color: #667789;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.drop-zone small {
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: #f4f7f9;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-process-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
border-right: 1px solid #d8e0e8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.preview-process-section {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.preview-process-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-process-header span {
|
||||
color: #7a8997;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-process-tree {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.preview-model-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e3e9ee;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.preview-model-meta span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-model-meta strong {
|
||||
color: #405469;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-process-tree li {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.preview-process-tree button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-process-tree li.is-active button {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.preview-process-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-scene-section {
|
||||
border-top: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-scene-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.preview-scene-body > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.preview-switch input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid span {
|
||||
color: #647586;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid input {
|
||||
min-height: 26px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.preview-canvas-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #647586;
|
||||
}
|
||||
|
||||
.dictionary-manager {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f3f6f8;
|
||||
}
|
||||
|
||||
.dictionary-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.dictionary-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafb 100%);
|
||||
}
|
||||
|
||||
.dictionary-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.dictionary-title i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #1f6f8b;
|
||||
box-shadow: 0 0 0 3px rgba(31, 111, 139, 0.12);
|
||||
}
|
||||
|
||||
.dictionary-title strong {
|
||||
color: #243447;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dictionary-count {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #d9e4ea;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #5f7182;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dictionary-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.dictionary-editor input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dictionary-save-btn,
|
||||
.dictionary-cancel-btn {
|
||||
min-width: 48px;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
.dictionary-list-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #f5f8fa;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
}
|
||||
|
||||
.dictionary-list-head span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dictionary-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 3px 0 6px;
|
||||
}
|
||||
|
||||
.dictionary-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid #eef2f5;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
.dictionary-item:nth-child(even) {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.dictionary-item:hover {
|
||||
background: #edf6f8;
|
||||
}
|
||||
|
||||
.dictionary-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #263746;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dictionary-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dictionary-edit-btn,
|
||||
.dictionary-delete-btn {
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.dictionary-edit-btn:hover {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
}
|
||||
|
||||
.danger-text-btn {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.danger-text-btn:hover {
|
||||
border-color: #f0b8b2;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.dictionary-empty {
|
||||
margin: 8px;
|
||||
padding: 42px 8px;
|
||||
border: 1px dashed #cdd8e1;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfd;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dictionary-manager {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.user-manager {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #f3f6f8;
|
||||
}
|
||||
|
||||
.user-manager-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.user-manager-head div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-manager-head strong {
|
||||
color: #243447;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-manager-head span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-manager-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.user-table-head,
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 1fr) 78px 68px 88px 88px 98px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-table-head {
|
||||
padding: 7px 12px;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #f5f8fa;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
}
|
||||
|
||||
.user-table-head span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-table-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 3px 0 6px;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
min-height: 38px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid #eef2f5;
|
||||
}
|
||||
|
||||
.user-row:nth-child(even) {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.user-row:hover {
|
||||
background: #edf6f8;
|
||||
}
|
||||
|
||||
.user-row > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.user-name strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #263746;
|
||||
}
|
||||
|
||||
.user-name em {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f6;
|
||||
color: #647586;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
border: 1px solid #d9e4ea;
|
||||
background: #f8fafb;
|
||||
color: #4d5f70;
|
||||
}
|
||||
|
||||
.role-badge.is-admin {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.status-badge.is-enabled {
|
||||
border: 1px solid #b9dec6;
|
||||
background: #edf8f0;
|
||||
color: #246b38;
|
||||
}
|
||||
|
||||
.status-badge.is-disabled {
|
||||
border: 1px solid #d8dde3;
|
||||
background: #f0f2f4;
|
||||
color: #6a7682;
|
||||
}
|
||||
|
||||
.status-badge.is-expired {
|
||||
border: 1px solid #f0c0b8;
|
||||
background: #fff1f0;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.user-row-actions button {
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.user-row-actions button:hover {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
}
|
||||
|
||||
.user-row-actions .danger-text-btn {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.user-empty {
|
||||
margin: 8px;
|
||||
padding: 48px 8px;
|
||||
border: 1px dashed #cdd8e1;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfd;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-editor-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.user-editor-title {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafb 100%);
|
||||
}
|
||||
|
||||
.user-editor-title strong {
|
||||
color: #243447;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-editor-title span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-editor-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 5px;
|
||||
padding: 24px;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-editor-empty strong {
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.user-editor-form {
|
||||
align-content: start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-editor-form select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.user-manager-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-manager-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.user-table-head,
|
||||
.user-row {
|
||||
min-width: 720px;
|
||||
}
|
||||
}
|
||||
109
web/src/pages/app/layout.ts
Normal file
109
web/src/pages/app/layout.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { w2layout, w2ui } from "w2ui";
|
||||
import { getCurrentUser, clearSession } from "../../services/authState";
|
||||
import { loadFolders } from "./modules/folders";
|
||||
import { bindModelActions } from "./modules/models";
|
||||
import { renderLogin } from "../login/login";
|
||||
import "./app.css";
|
||||
import "./dialogs.css";
|
||||
|
||||
export async function renderApp() {
|
||||
const currentUser = getCurrentUser();
|
||||
const isAdmin = currentUser?.role === "admin";
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<strong>DMT 模型库</strong>
|
||||
<span id="folderCrumb">未选择目录</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span>${currentUser?.username ?? ""}</span>
|
||||
<button id="logoutBtn" class="icon-text-btn">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
<section id="workspaceLayout" class="workspace"></section>
|
||||
<template id="treePanelTemplate">
|
||||
<aside class="tree-pane">
|
||||
<div id="folderTree"></div>
|
||||
</aside>
|
||||
</template>
|
||||
<template id="modelPanelTemplate">
|
||||
<main class="content-pane">
|
||||
<div class="content-toolbar">
|
||||
<div>
|
||||
<h2>模型列表</h2>
|
||||
<p id="modelCount">0 个模型</p>
|
||||
</div>
|
||||
<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>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-toolbar">
|
||||
<label>
|
||||
<span>品牌</span>
|
||||
<select id="brandFilter">
|
||||
<option value="">全部品牌</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>类型</span>
|
||||
<select id="typeFilter">
|
||||
<option value="">全部类型</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="keyword-filter">
|
||||
<span>关键字</span>
|
||||
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
||||
</label>
|
||||
<button id="resetFilterBtn" type="button">重置</button>
|
||||
</div>
|
||||
<div id="modelGrid" class="model-grid"></div>
|
||||
<footer id="modelPagination" class="pagination"></footer>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderWorkspaceLayout();
|
||||
|
||||
document.querySelector("#logoutBtn")!.addEventListener("click", () => {
|
||||
clearSession();
|
||||
renderLogin(renderApp);
|
||||
});
|
||||
|
||||
bindModelActions();
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
function renderWorkspaceLayout() {
|
||||
const workspace = document.querySelector<HTMLDivElement>("#workspaceLayout")!;
|
||||
const treeTemplate = document.querySelector<HTMLTemplateElement>("#treePanelTemplate")!;
|
||||
const modelTemplate = document.querySelector<HTMLTemplateElement>("#modelPanelTemplate")!;
|
||||
const existingLayout = w2ui.modelLibraryLayout;
|
||||
if (existingLayout?.destroy) {
|
||||
existingLayout.destroy();
|
||||
}
|
||||
const layout = new w2layout({
|
||||
name: "modelLibraryLayout",
|
||||
padding: 0,
|
||||
panels: [
|
||||
{
|
||||
type: "left",
|
||||
size: 300,
|
||||
minSize: 220,
|
||||
resizable: true,
|
||||
overflow: "hidden",
|
||||
html: treeTemplate.innerHTML
|
||||
},
|
||||
{
|
||||
type: "main",
|
||||
overflow: "hidden",
|
||||
html: modelTemplate.innerHTML
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
layout.render(workspace);
|
||||
}
|
||||
175
web/src/pages/app/modules/dictionaries.ts
Normal file
175
web/src/pages/app/modules/dictionaries.ts
Normal 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();
|
||||
}
|
||||
125
web/src/pages/app/modules/folders.ts
Normal file
125
web/src/pages/app/modules/folders.ts
Normal 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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
248
web/src/pages/app/modules/preview.ts
Normal file
248
web/src/pages/app/modules/preview.ts
Normal 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;
|
||||
}
|
||||
266
web/src/pages/app/modules/users.ts
Normal file
266
web/src/pages/app/modules/users.ts
Normal 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) : "";
|
||||
}
|
||||
52
web/src/pages/login/login.css
Normal file
52
web/src/pages/login/login.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #dce8ef, #f4f6f0);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(360px, 100%);
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9e0e6;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 28px rgba(40, 58, 75, 0.12);
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-panel p {
|
||||
margin: 0 0 18px;
|
||||
color: #667789;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-stack label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 78px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.captcha-box {
|
||||
background: repeating-linear-gradient(45deg, #edf3f6, #edf3f6 8px, #dbe6ec 8px, #dbe6ec 16px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 20px;
|
||||
color: #b42318;
|
||||
}
|
||||
76
web/src/pages/login/login.ts
Normal file
76
web/src/pages/login/login.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { AuthUser } from "../../types";
|
||||
import { api } from "../../services/api";
|
||||
import { setSession } from "../../services/authState";
|
||||
import { notify, notifyError } from "../../ui/dialogs";
|
||||
import "./login.css";
|
||||
|
||||
export function renderLogin(onSuccess: () => Promise<void> | void) {
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<main class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div>
|
||||
<h1>DMT 模型库</h1>
|
||||
<p>模型文件、目录和属性维护</p>
|
||||
</div>
|
||||
<form id="loginForm" class="form-stack">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input name="username" autocomplete="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" autocomplete="current-password" value="admin123" />
|
||||
</label>
|
||||
<label>
|
||||
<span>图片验证码</span>
|
||||
<div class="captcha-row">
|
||||
<input name="captcha" value="1234" />
|
||||
<button type="button" class="captcha-box">1234</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="primary-btn" type="submit">登录</button>
|
||||
<button class="ghost-btn" id="registerBtn" type="button">注册普通账号</button>
|
||||
<p id="loginMessage" class="message"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
document.querySelector<HTMLFormElement>("#loginForm")!.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
try {
|
||||
const result = await api<{ token: string; user: AuthUser }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
captcha: form.get("captcha")
|
||||
})
|
||||
});
|
||||
setSession(result.token, result.user);
|
||||
await onSuccess();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
document.querySelector("#loginMessage")!.textContent = message;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector<HTMLButtonElement>("#registerBtn")!.addEventListener("click", async () => {
|
||||
const form = new FormData(document.querySelector<HTMLFormElement>("#loginForm")!);
|
||||
try {
|
||||
await api("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
captcha: form.get("captcha")
|
||||
})
|
||||
});
|
||||
notify("注册成功,可以登录");
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
23
web/src/services/api.ts
Normal file
23
web/src/services/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getToken } from "./authState";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||
...authHeaders(),
|
||||
...((options.headers as Record<string, string> | undefined) ?? {})
|
||||
} as HeadersInit
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message ?? "请求失败");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
29
web/src/services/authState.ts
Normal file
29
web/src/services/authState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
let token = localStorage.getItem("token") ?? "";
|
||||
let currentUser: AuthUser | null = null;
|
||||
|
||||
export function getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
export function setSession(nextToken: string, user: AuthUser) {
|
||||
token = nextToken;
|
||||
currentUser = user;
|
||||
localStorage.setItem("token", nextToken);
|
||||
}
|
||||
|
||||
export function setCurrentUser(user: AuthUser) {
|
||||
currentUser = user;
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
token = "";
|
||||
currentUser = null;
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
103
web/src/styles/base.css
Normal file
103
web/src/styles/base.css
Normal file
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1c2733;
|
||||
background: #eef2f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #c6d0da;
|
||||
background: #ffffff;
|
||||
color: #243447;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid #c9d3dd;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: #1f6f8b;
|
||||
border-color: #1f6f8b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: #f7fafb;
|
||||
}
|
||||
|
||||
.w2ui-panel .w2ui-panel-content {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jstree-default .jstree-anchor {
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.jstree-default .jstree-icon:empty {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.vakata-context li > a {
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0 18px 0 8px;
|
||||
}
|
||||
|
||||
.vakata-context,
|
||||
.vakata-context ul {
|
||||
z-index: 12000 !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 1px 6px 0 2px;
|
||||
background-position: center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: 16px 16px !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon-add {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 6.5A2.5 2.5 0 0 1 5.5 4H10l2 2h6.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-10Z' stroke='%231f6f8b' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M12 9.5v6M9 12.5h6' stroke='%231f6f8b' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon-edit {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 20h4.5L19.2 9.3a2.1 2.1 0 0 0 0-3L17.7 4.8a2.1 2.1 0 0 0-3 0L4 15.5V20Z' stroke='%2374612f' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M13.5 6 18 10.5' stroke='%23c08a28' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon-delete {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7h14' stroke='%23b42318' stroke-width='1.8' stroke-linecap='round'/%3E%3Cpath d='M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7' stroke='%23b42318' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M7 7l1 12a2 2 0 0 0 2 1.8h4a2 2 0 0 0 2-1.8l1-12' stroke='%23b42318' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M10.5 11v6M13.5 11v6' stroke='%23b42318' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
70
web/src/types.ts
Normal file
70
web/src/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
export type UserRole = "admin" | "user";
|
||||
|
||||
export type ManagedUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
enabled: boolean;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UserListResponse = {
|
||||
users: ManagedUser[];
|
||||
};
|
||||
|
||||
export type Folder = {
|
||||
id: number;
|
||||
parent_id: number | null;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ModelItem = {
|
||||
id: number;
|
||||
folder_id: number;
|
||||
brand_id: number | null;
|
||||
type_id: number | null;
|
||||
name: string;
|
||||
original_filename: string;
|
||||
file_path: string;
|
||||
file_url: string;
|
||||
storage_provider: "local" | "cos";
|
||||
file_size: number;
|
||||
thumbnail: string | null;
|
||||
thumbnail_path: string | null;
|
||||
thumbnail_provider: "local" | "cos" | null;
|
||||
thumbnail_url: string | null;
|
||||
brand_name: string | null;
|
||||
type_name: string | null;
|
||||
properties: Record<string, string>;
|
||||
};
|
||||
|
||||
export type FolderTreeResponse = {
|
||||
folders: Folder[];
|
||||
tree: unknown[];
|
||||
};
|
||||
|
||||
export type ModelListResponse = {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: ModelItem[];
|
||||
};
|
||||
|
||||
export type DictionaryItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DictionaryResponse = {
|
||||
brands: DictionaryItem[];
|
||||
types: DictionaryItem[];
|
||||
};
|
||||
26
web/src/types/three-examples.d.ts
vendored
Normal file
26
web/src/types/three-examples.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
declare module "three/examples/jsm/controls/OrbitControls.js" {
|
||||
import { Camera, EventDispatcher, Vector3 } from "three";
|
||||
|
||||
export class OrbitControls extends EventDispatcher {
|
||||
constructor(object: Camera, domElement?: HTMLElement);
|
||||
target: Vector3;
|
||||
enableDamping: boolean;
|
||||
minDistance: number;
|
||||
maxDistance: number;
|
||||
update(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "three/examples/jsm/loaders/GLTFLoader.js" {
|
||||
import { LoadingManager, Object3D } from "three";
|
||||
|
||||
export type GLTF = {
|
||||
scene: Object3D;
|
||||
};
|
||||
|
||||
export class GLTFLoader {
|
||||
constructor(manager?: LoadingManager);
|
||||
loadAsync(url: string): Promise<GLTF>;
|
||||
}
|
||||
}
|
||||
47
web/src/types/w2ui.d.ts
vendored
Normal file
47
web/src/types/w2ui.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
declare module "w2ui" {
|
||||
type W2EventHandler = (event: unknown) => void;
|
||||
type W2EventHost = {
|
||||
on(eventName: string, handler: W2EventHandler): W2EventHost;
|
||||
};
|
||||
|
||||
export const w2popup: {
|
||||
open(options: {
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modal?: boolean;
|
||||
body?: string;
|
||||
actions?: Record<string, () => void | Promise<void>>;
|
||||
}): {
|
||||
self: W2EventHost;
|
||||
};
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export class w2layout {
|
||||
constructor(options: {
|
||||
name: string;
|
||||
padding?: number;
|
||||
panels: Array<{
|
||||
type: "left" | "right" | "top" | "bottom" | "main" | "preview";
|
||||
size?: number | string;
|
||||
minSize?: number;
|
||||
maxSize?: number | false;
|
||||
resizable?: boolean;
|
||||
overflow?: string;
|
||||
html?: string;
|
||||
}>;
|
||||
});
|
||||
render(target: HTMLElement): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export const w2ui: Record<string, { destroy?: () => void } | undefined>;
|
||||
|
||||
export function w2alert(message: string, title?: string): unknown;
|
||||
export function w2confirm(
|
||||
message: string | { msg: string; title?: string; yes?: string; no?: string },
|
||||
title?: string,
|
||||
callback?: (action: string) => void
|
||||
): unknown;
|
||||
}
|
||||
112
web/src/ui/dialogs.ts
Normal file
112
web/src/ui/dialogs.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { w2alert, w2confirm, w2popup } from "w2ui";
|
||||
|
||||
type PopupActionEvent = {
|
||||
detail: {
|
||||
action: string;
|
||||
self?: {
|
||||
close?: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function notify(message: string, title = "提示") {
|
||||
w2alert(message, title);
|
||||
}
|
||||
|
||||
export function notifyError(error: unknown) {
|
||||
notify(error instanceof Error ? error.message : String(error), "错误");
|
||||
}
|
||||
|
||||
export function confirmDialog(message: string, title = "确认") {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
w2confirm({
|
||||
msg: message,
|
||||
title,
|
||||
yes: "确定",
|
||||
no: "取消"
|
||||
}, undefined, (action: string) => {
|
||||
resolve(action === "yes" || action === "Yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function formDialog<T>(options: {
|
||||
title: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
body: string;
|
||||
onOpen?: () => void;
|
||||
onSubmit: () => Promise<T> | T;
|
||||
}) {
|
||||
return new Promise<T | null>((resolve) => {
|
||||
let settled = false;
|
||||
const closeWith = (value: T | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
w2popup.close();
|
||||
};
|
||||
|
||||
const popup = w2popup.open({
|
||||
title: options.title,
|
||||
width: options.width ?? 460,
|
||||
height: options.height ?? 300,
|
||||
modal: true,
|
||||
body: options.body,
|
||||
actions: {
|
||||
取消() {
|
||||
closeWith(null);
|
||||
},
|
||||
async 确定() {
|
||||
try {
|
||||
const result = await options.onSubmit();
|
||||
closeWith(result);
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
popup.self
|
||||
.on("open:after", () => options.onOpen?.())
|
||||
.on("close:after", () => {
|
||||
if (!settled) resolve(null);
|
||||
})
|
||||
.on("action:after", (event: unknown) => {
|
||||
const popupEvent = event as PopupActionEvent;
|
||||
if (popupEvent.detail.action === "close") {
|
||||
closeWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function promptDialog(options: {
|
||||
title: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const id = `field-${Date.now()}`;
|
||||
return formDialog<string>({
|
||||
title: options.title,
|
||||
height: 210,
|
||||
body: `
|
||||
<div class="popup-form">
|
||||
<label>
|
||||
<span>${options.label}</span>
|
||||
<input id="${id}" value="${options.value ?? ""}" placeholder="${options.placeholder ?? ""}" />
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
onOpen: () => document.querySelector<HTMLInputElement>(`#${id}`)?.focus(),
|
||||
onSubmit: () => {
|
||||
const value = document.querySelector<HTMLInputElement>(`#${id}`)?.value.trim() ?? "";
|
||||
if (!value) {
|
||||
throw new Error(`${options.label}不能为空`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
20
web/src/utils/format.ts
Normal file
20
web/src/utils/format.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char]!));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function modelNameFromFile(fileName: string) {
|
||||
return fileName.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user