initial commit

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

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
HOST=0.0.0.0
PORT=3001
JWT_SECRET=change-me
DB_PATH=
# local: write to server/storage and serve via /storage
# cos: upload models to Tencent Cloud COS and read through CDN_BASE_URL
STORAGE_DRIVER=local
# Optional when STORAGE_DRIVER=local and public absolute URLs are needed.
PUBLIC_BASE_URL=
# Required when STORAGE_DRIVER=cos
COS_SECRET_ID=
COS_SECRET_KEY=
COS_BUCKET=
COS_REGION=
COS_PREFIX=models
CDN_BASE_URL=https://cdn.example.com

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.env
.idea/
*.log
server/data/*.db
server/data/*.db-*
server/storage/

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# DMT Model Library
模型库后台服务及 Web 界面。
## 启动
```bash
npm install
npm run dev
```
默认地址:
- Web: http://localhost:5174
- API: http://localhost:3001
初始化管理员账号:
- 用户名:`admin`
- 密码:`admin123`

6270
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "dmt-model-library",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev -w server\" \"npm run dev -w web\"",
"build": "npm run build -w server && npm run build -w web",
"start": "npm run start -w server"
},
"workspaces": [
"server",
"web"
],
"devDependencies": {
"concurrently": "^9.1.2"
}
}

29
server/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "dmt-model-library-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.0.3",
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.15.4",
"dotenv": "^17.4.2",
"fastify": "^5.2.1",
"nanoid": "^5.0.9",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^24.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

227
server/src/auth.ts Normal file
View File

@@ -0,0 +1,227 @@
import bcrypt from "bcryptjs";
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "./db.js";
import { AuthUser, UserRow } from "./types.js";
declare module "fastify" {
interface FastifyRequest {
userInfo: AuthUser;
}
}
const registerSchema = z.object({
username: z.string().min(3).max(32),
password: z.string().min(6).max(128),
captcha: z.string().optional()
});
const loginSchema = registerSchema.pick({
username: true,
password: true
}).extend({
captcha: z.string().optional()
});
const userCreateSchema = z.object({
username: z.string().min(3).max(32),
password: z.string().min(6).max(128),
role: z.enum(["admin", "user"]).default("user"),
enabled: z.boolean().default(true),
expiresAt: z.string().nullable().optional()
});
const userUpdateSchema = z.object({
role: z.enum(["admin", "user"]),
enabled: z.boolean(),
expiresAt: z.string().nullable().optional(),
password: z.string().min(6).max(128).optional()
});
function publicUser(row: UserRow) {
return {
id: row.id,
username: row.username,
role: row.role,
enabled: Boolean(row.enabled),
expires_at: row.expires_at,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function normalizeExpiresAt(value: string | null | undefined) {
const clean = value?.trim();
if (!clean) return null;
const date = new Date(clean);
if (Number.isNaN(date.getTime())) {
throw new Error("授权到期时间格式不正确");
}
return clean.length === 10 ? `${clean}T23:59:59` : clean;
}
export async function authRoutes(app: FastifyInstance) {
app.post("/api/auth/register", async (request, reply) => {
const body = registerSchema.parse(request.body);
const passwordHash = await bcrypt.hash(body.password, 10);
try {
db.prepare("INSERT INTO users (username, password_hash, role, enabled) VALUES (?, ?, 'user', 1)")
.run(body.username, passwordHash);
return reply.code(201).send({ ok: true });
} catch {
return reply.code(409).send({ message: "用户名已存在" });
}
});
app.post("/api/auth/login", async (request, reply) => {
const body = loginSchema.parse(request.body);
const user = db.prepare(`
SELECT id, username, password_hash, role, enabled, expires_at
FROM users
WHERE username = ?
`).get(body.username) as {
id: number;
username: string;
password_hash: string;
role: "admin" | "user";
enabled: number;
expires_at: string | null;
} | undefined;
if (!user || !(await bcrypt.compare(body.password, user.password_hash))) {
return reply.code(401).send({ message: "用户名或密码错误" });
}
if (!user.enabled) {
return reply.code(403).send({ message: "账号已禁用" });
}
if (user.expires_at && new Date(user.expires_at).getTime() < Date.now()) {
return reply.code(403).send({ message: "账号授权已过期" });
}
const token = app.jwt.sign({ id: user.id, username: user.username, role: user.role });
return { token, user: { id: user.id, username: user.username, role: user.role } };
});
app.get("/api/auth/me", { preHandler: [requireAuth] }, async (request) => {
return { user: request.userInfo };
});
app.get("/api/users", { preHandler: [requireAdmin] }, async () => {
const rows = db.prepare(`
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
FROM users
ORDER BY id ASC
`).all() as unknown as UserRow[];
return { users: rows.map(publicUser) };
});
app.post("/api/users", { preHandler: [requireAdmin] }, async (request, reply) => {
const body = userCreateSchema.parse(request.body);
const passwordHash = await bcrypt.hash(body.password, 10);
try {
const result = db.prepare(`
INSERT INTO users (username, password_hash, role, enabled, expires_at)
VALUES (?, ?, ?, ?, ?)
`).run(body.username.trim(), passwordHash, body.role, body.enabled ? 1 : 0, normalizeExpiresAt(body.expiresAt));
const row = db.prepare(`
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
FROM users
WHERE id = ?
`).get(Number(result.lastInsertRowid)) as unknown as UserRow;
return reply.code(201).send({ user: publicUser(row) });
} catch (error) {
if (error instanceof Error && error.message.includes("授权到期时间")) {
return reply.code(400).send({ message: error.message });
}
return reply.code(409).send({ message: "用户名已存在" });
}
});
app.put("/api/users/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
const body = userUpdateSchema.parse(request.body);
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(params.id) as UserRow | undefined;
if (!user) {
return reply.code(404).send({ message: "用户不存在" });
}
if (user.id === request.userInfo.id && (!body.enabled || body.role !== "admin")) {
return reply.code(400).send({ message: "不能取消当前登录管理员的权限" });
}
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND enabled = 1")
.get() as { count: number };
if (user.role === "admin" && user.enabled && (body.role !== "admin" || !body.enabled) && adminCount.count <= 1) {
return reply.code(400).send({ message: "至少保留一个启用的管理员" });
}
try {
const expiresAt = normalizeExpiresAt(body.expiresAt);
if (body.password) {
const passwordHash = await bcrypt.hash(body.password, 10);
db.prepare(`
UPDATE users
SET role = ?, enabled = ?, expires_at = ?, password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(body.role, body.enabled ? 1 : 0, expiresAt, passwordHash, user.id);
} else {
db.prepare(`
UPDATE users
SET role = ?, enabled = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(body.role, body.enabled ? 1 : 0, expiresAt, user.id);
}
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : "保存失败";
return reply.code(400).send({ message });
}
});
app.delete("/api/users/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
if (params.id === request.userInfo.id) {
return reply.code(400).send({ message: "不能删除当前登录用户" });
}
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(params.id) as UserRow | undefined;
if (!user) {
return reply.code(404).send({ message: "用户不存在" });
}
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND enabled = 1")
.get() as { count: number };
if (user.role === "admin" && user.enabled && adminCount.count <= 1) {
return reply.code(400).send({ message: "至少保留一个启用的管理员" });
}
db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
return { ok: true };
});
}
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
try {
const payload = await request.jwtVerify<AuthUser>();
const user = db.prepare("SELECT id, username, role, enabled, expires_at FROM users WHERE id = ?")
.get(payload.id) as Pick<UserRow, "id" | "username" | "role" | "enabled" | "expires_at"> | undefined;
if (!user) {
return reply.code(401).send({ message: "请先登录" });
}
if (!user.enabled) {
return reply.code(403).send({ message: "账号已禁用" });
}
if (user.expires_at && new Date(user.expires_at).getTime() < Date.now()) {
return reply.code(403).send({ message: "账号授权已过期" });
}
request.userInfo = {
id: user.id,
username: user.username,
role: user.role
};
} catch {
return reply.code(401).send({ message: "请先登录" });
}
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
await requireAuth(request, reply);
if (reply.sent) return;
if (request.userInfo.role !== "admin") {
return reply.code(403).send({ message: "需要管理员权限" });
}
}

28
server/src/config.ts Normal file
View File

@@ -0,0 +1,28 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const serverRoot = path.resolve(__dirname, "..");
dotenv.config({ path: path.resolve(serverRoot, "..", ".env") });
dotenv.config({ path: path.resolve(serverRoot, ".env") });
export const config = {
host: process.env.HOST ?? "0.0.0.0",
port: Number(process.env.PORT ?? 3001),
jwtSecret: process.env.JWT_SECRET ?? "dev-only-change-me",
dataDir: path.resolve(serverRoot, "data"),
storageDir: path.resolve(serverRoot, "storage"),
databasePath: process.env.DB_PATH ?? path.resolve(serverRoot, "data", "model-library.db"),
storageDriver: (process.env.STORAGE_DRIVER ?? "local").toLowerCase(),
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? "",
cdnBaseUrl: process.env.CDN_BASE_URL ?? "",
cos: {
secretId: process.env.COS_SECRET_ID ?? "",
secretKey: process.env.COS_SECRET_KEY ?? "",
bucket: process.env.COS_BUCKET ?? "",
region: process.env.COS_REGION ?? "",
prefix: (process.env.COS_PREFIX ?? "").replace(/^\/+|\/+$/g, "")
}
};

119
server/src/db.ts Normal file
View File

@@ -0,0 +1,119 @@
import fs from "node:fs";
import { DatabaseSync } from "node:sqlite";
import bcrypt from "bcryptjs";
import { config } from "./config.js";
fs.mkdirSync(config.dataDir, { recursive: true });
fs.mkdirSync(config.storageDir, { recursive: true });
export const db = new DatabaseSync(config.databasePath);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
function hasColumn(table: string, column: string) {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[];
return rows.some((row) => row.name === column);
}
export function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(parent_id, name)
);
CREATE TABLE IF NOT EXISTS brands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS model_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
brand_id INTEGER REFERENCES brands(id) ON DELETE SET NULL,
type_id INTEGER REFERENCES model_types(id) ON DELETE SET NULL,
name TEXT NOT NULL,
original_filename TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
storage_provider TEXT NOT NULL DEFAULT 'local',
file_size INTEGER NOT NULL,
thumbnail TEXT,
thumbnail_path TEXT,
thumbnail_provider TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(folder_id, name)
);
CREATE TABLE IF NOT EXISTS model_properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
property_key TEXT NOT NULL,
property_value TEXT NOT NULL DEFAULT '',
UNIQUE(model_id, property_key)
);
CREATE INDEX IF NOT EXISTS idx_folders_parent_id ON folders(parent_id);
CREATE INDEX IF NOT EXISTS idx_models_folder_id ON models(folder_id);
CREATE INDEX IF NOT EXISTS idx_model_properties_model_id ON model_properties(model_id);
`);
if (!hasColumn("models", "brand_id")) {
db.exec("ALTER TABLE models ADD COLUMN brand_id INTEGER REFERENCES brands(id) ON DELETE SET NULL");
}
if (!hasColumn("models", "type_id")) {
db.exec("ALTER TABLE models ADD COLUMN type_id INTEGER REFERENCES model_types(id) ON DELETE SET NULL");
}
if (!hasColumn("models", "storage_provider")) {
db.exec("ALTER TABLE models ADD COLUMN storage_provider TEXT NOT NULL DEFAULT 'local'");
}
if (!hasColumn("models", "thumbnail_path")) {
db.exec("ALTER TABLE models ADD COLUMN thumbnail_path TEXT");
}
if (!hasColumn("models", "thumbnail_provider")) {
db.exec("ALTER TABLE models ADD COLUMN thumbnail_provider TEXT");
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_models_brand_id ON models(brand_id);
CREATE INDEX IF NOT EXISTS idx_models_type_id ON models(type_id);
`);
const rootFolder = db.prepare("SELECT id FROM folders WHERE parent_id IS NULL AND name = ?").get("模型库");
if (!rootFolder) {
db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (NULL, ?, ?)").run("模型库", "模型库");
}
const admin = db.prepare("SELECT id FROM users WHERE username = ?").get("admin");
if (!admin) {
db.prepare(`
INSERT INTO users (username, password_hash, role, enabled)
VALUES (?, ?, 'admin', 1)
`).run("admin", bcrypt.hashSync("admin123", 10));
}
}

154
server/src/folders.ts Normal file
View File

@@ -0,0 +1,154 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "./db.js";
import { requireAdmin, requireAuth } from "./auth.js";
import { storage } from "./storage.js";
import { FolderRow } from "./types.js";
import { assertValidFolderName, ensureDir, removeIfExists, safeStoragePath, toFolderStorageRelative } from "./utils.js";
const createSchema = z.object({
parentId: z.number().int().nullable().optional(),
name: z.string()
});
const updateSchema = z.object({
name: z.string()
});
function buildTree(rows: FolderRow[]) {
const nodes = rows.map((row) => ({
id: String(row.id),
text: row.name,
parentId: row.parent_id,
path: row.path,
children: [] as unknown[]
}));
const byId = new Map(nodes.map((node) => [Number(node.id), node]));
const roots = [];
for (const node of nodes) {
if (node.parentId && byId.has(node.parentId)) {
byId.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function getFolder(id: number) {
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
}
function childPath(parent: FolderRow | undefined, name: string) {
return parent ? path.posix.join(parent.path, name) : name;
}
function assertCanRenameRoot(folder: FolderRow) {
if (folder.parent_id === null) {
throw new Error("根目录不能重命名");
}
}
export async function folderRoutes(app: FastifyInstance) {
app.get("/api/folders", { preHandler: [requireAuth] }, async () => {
const rows = db.prepare("SELECT * FROM folders ORDER BY parent_id, name").all() as unknown as FolderRow[];
return { folders: rows, tree: buildTree(rows) };
});
app.post("/api/folders", { preHandler: [requireAdmin] }, async (request, reply) => {
const body = createSchema.parse(request.body);
const name = assertValidFolderName(body.name);
const parent = body.parentId ? getFolder(body.parentId) : undefined;
if (body.parentId && !parent) {
return reply.code(404).send({ message: "父目录不存在" });
}
const folderPath = childPath(parent, name);
try {
const result = db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (?, ?, ?)")
.run(body.parentId ?? null, name, folderPath);
await ensureDir(safeStoragePath(toFolderStorageRelative(folderPath)));
return reply.code(201).send({ id: result.lastInsertRowid, name, path: folderPath });
} catch {
return reply.code(409).send({ message: "同级目录已存在" });
}
});
app.put("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
const body = updateSchema.parse(request.body);
const name = assertValidFolderName(body.name);
const folder = getFolder(params.id);
if (!folder) {
return reply.code(404).send({ message: "目录不存在" });
}
assertCanRenameRoot(folder);
const parent = folder.parent_id ? getFolder(folder.parent_id) : undefined;
const oldPath = folder.path;
const newPath = childPath(parent, name);
if (storage.provider !== "local") {
const modelCount = db.prepare("SELECT COUNT(*) AS count FROM models WHERE file_path LIKE ?")
.get(`models/${oldPath}/%`) as { count: number };
if (modelCount.count > 0) {
return reply.code(400).send({ message: "当前存储模式下,包含模型的目录不能重命名" });
}
}
const tx = () => {
db.exec("BEGIN");
try {
db.prepare("UPDATE folders SET name = ?, path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(name, newPath, folder.id);
const descendants = db.prepare("SELECT * FROM folders WHERE path LIKE ? ORDER BY LENGTH(path)")
.all(`${oldPath}/%`) as unknown as FolderRow[];
for (const item of descendants) {
const replacedPath = item.path.replace(oldPath + "/", newPath + "/");
db.prepare("UPDATE folders SET path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(replacedPath, item.id);
}
if (storage.provider === "local") {
const models = db.prepare("SELECT id, file_path FROM models WHERE file_path LIKE ?")
.all(`models/${oldPath}/%`) as { id: number; file_path: string }[];
for (const model of models) {
db.prepare("UPDATE models SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(model.file_path.replace(`models/${oldPath}/`, `models/${newPath}/`), model.id);
}
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
};
try {
tx();
if (storage.provider === "local") {
await fs.rename(
safeStoragePath(toFolderStorageRelative(oldPath)),
safeStoragePath(toFolderStorageRelative(newPath))
);
}
return { ok: true };
} catch {
return reply.code(409).send({ message: "目录重命名失败,可能存在重名目录" });
}
});
app.delete("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
const folder = getFolder(params.id);
if (!folder) {
return reply.code(404).send({ message: "目录不存在" });
}
if (folder.parent_id === null) {
return reply.code(400).send({ message: "根目录不能删除" });
}
db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id);
await removeIfExists(safeStoragePath(toFolderStorageRelative(folder.path)));
return { ok: true };
});
}

52
server/src/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import multipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { config } from "./config.js";
import { migrate } from "./db.js";
import { authRoutes } from "./auth.js";
import { folderRoutes } from "./folders.js";
import { modelRoutes } from "./models.js";
import { storageStatus } from "./storage.js";
migrate();
const app = Fastify({
logger: true,
bodyLimit: 1024 * 1024 * 20
});
await app.register(cors, {
origin: true,
credentials: true
});
await app.register(jwt, {
secret: config.jwtSecret
});
await app.register(multipart, {
limits: {
fileSize: 1024 * 1024 * 200
}
});
await app.register(fastifyStatic, {
root: config.storageDir,
prefix: "/storage/"
});
app.get("/api/health", async () => ({ ok: true, storage: storageStatus() }));
await app.register(authRoutes);
await app.register(folderRoutes);
await app.register(modelRoutes);
app.setErrorHandler((error, _request, reply) => {
const fastifyError = error as { statusCode?: number; message?: string };
const statusCode = fastifyError.statusCode && fastifyError.statusCode >= 400 ? fastifyError.statusCode : 500;
app.log.error(error);
reply.code(statusCode).send({
message: statusCode === 500 ? "服务器内部错误" : fastifyError.message
});
});
await app.listen({ host: config.host, port: config.port });

340
server/src/models.ts Normal file
View File

@@ -0,0 +1,340 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { nanoid } from "nanoid";
import { db } from "./db.js";
import { requireAdmin, requireAuth } from "./auth.js";
import { deleteStoredObject, publicObjectUrl, storage } from "./storage.js";
import { DictionaryRow, FolderRow, ModelRow } from "./types.js";
import {
assertGlbFilename,
assertValidFolderName,
normalizeModelName,
toStorageRelative
} from "./utils.js";
const updateSchema = z.object({
name: z.string().min(1),
brandName: z.string().optional(),
typeName: z.string().optional(),
thumbnail: z.string().nullable().optional(),
properties: z.record(z.string()).optional()
});
const thumbnailSchema = z.object({
thumbnail: z.string().startsWith("data:image/").max(2_000_000)
});
const dictionaryKindSchema = z.object({
kind: z.enum(["brands", "types"])
});
const dictionaryBodySchema = z.object({
name: z.string().min(1).max(80)
});
function getFolder(id: number) {
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
}
function getProperties(modelId: number) {
const rows = db.prepare("SELECT property_key, property_value FROM model_properties WHERE model_id = ?")
.all(modelId) as { property_key: string; property_value: string }[];
return Object.fromEntries(rows.map((row) => [row.property_key, row.property_value]));
}
function saveProperties(modelId: number, properties: Record<string, string>) {
const stmt = db.prepare(`
INSERT INTO model_properties (model_id, property_key, property_value)
VALUES (?, ?, ?)
ON CONFLICT(model_id, property_key)
DO UPDATE SET property_value = excluded.property_value
`);
for (const [key, value] of Object.entries(properties)) {
const cleanKey = key.trim();
if (!cleanKey) continue;
stmt.run(modelId, cleanKey, String(value ?? ""));
}
}
function modelPayload(row: ModelRow) {
return {
...row,
file_url: publicObjectUrl(row.file_path, row.storage_provider),
thumbnail_url: row.thumbnail_path && row.thumbnail_provider
? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider)
: row.thumbnail,
properties: getProperties(row.id)
};
}
function dataImageToBuffer(dataUrl: string) {
const match = dataUrl.match(/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/);
if (!match) {
throw new Error("缩略图格式不正确");
}
return {
ext: match[1] === "image/png" ? "png" : match[1] === "image/webp" ? "webp" : "jpg",
buffer: Buffer.from(match[2], "base64")
};
}
function normalizeDictionaryName(name: string | undefined) {
const clean = name?.trim();
return clean || null;
}
function findOrCreateDictionary(table: "brands" | "model_types", name: string | undefined) {
const cleanName = normalizeDictionaryName(name);
if (!cleanName) return null;
const existing = db.prepare(`SELECT id FROM ${table} WHERE name = ?`).get(cleanName) as { id: number } | undefined;
if (existing) return existing.id;
const result = db.prepare(`INSERT INTO ${table} (name) VALUES (?)`).run(cleanName);
return Number(result.lastInsertRowid);
}
function dictionaryList(table: "brands" | "model_types") {
return db.prepare(`SELECT id, name FROM ${table} ORDER BY name`).all() as unknown as DictionaryRow[];
}
function dictionaryTable(kind: "brands" | "types") {
return kind === "brands" ? "brands" : "model_types";
}
export async function modelRoutes(app: FastifyInstance) {
app.get("/api/dictionaries", { preHandler: [requireAuth] }, async () => {
return {
brands: dictionaryList("brands"),
types: dictionaryList("model_types")
};
});
app.post("/api/dictionaries/:kind", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = dictionaryKindSchema.parse(request.params);
const body = dictionaryBodySchema.parse(request.body);
const name = body.name.trim();
const table = dictionaryTable(params.kind);
try {
const result = db.prepare(`INSERT INTO ${table} (name) VALUES (?)`).run(name);
return reply.code(201).send({ id: result.lastInsertRowid, name });
} catch {
return reply.code(409).send({ message: "名称已存在" });
}
});
app.put("/api/dictionaries/:kind/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = dictionaryKindSchema.extend({ id: z.coerce.number().int() }).parse(request.params);
const body = dictionaryBodySchema.parse(request.body);
const name = body.name.trim();
const table = dictionaryTable(params.kind);
try {
const result = db.prepare(`UPDATE ${table} SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
.run(name, params.id);
if (result.changes === 0) {
return reply.code(404).send({ message: "字典项不存在" });
}
return { ok: true };
} catch {
return reply.code(409).send({ message: "名称已存在" });
}
});
app.delete("/api/dictionaries/:kind/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = dictionaryKindSchema.extend({ id: z.coerce.number().int() }).parse(request.params);
const table = dictionaryTable(params.kind);
const result = db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(params.id);
if (result.changes === 0) {
return reply.code(404).send({ message: "字典项不存在" });
}
return { ok: true };
});
app.get("/api/models", { preHandler: [requireAuth] }, async (request) => {
const query = z.object({
folderId: z.coerce.number().int().optional(),
brandId: z.coerce.number().int().optional(),
typeId: z.coerce.number().int().optional(),
keyword: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(12)
}).parse(request.query);
const offset = (query.page - 1) * query.pageSize;
const whereParts: string[] = [];
const params: Array<string | number> = [];
if (query.folderId) {
whereParts.push("m.folder_id = ?");
params.push(query.folderId);
}
if (query.brandId) {
whereParts.push("m.brand_id = ?");
params.push(query.brandId);
}
if (query.typeId) {
whereParts.push("m.type_id = ?");
params.push(query.typeId);
}
if (query.keyword?.trim()) {
whereParts.push("(m.name LIKE ? OR m.original_filename LIKE ? OR mp.property_value LIKE ?)");
const like = `%${query.keyword.trim()}%`;
params.push(like, like, like);
}
const where = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : "";
const totalStmt = db.prepare(`
SELECT COUNT(DISTINCT m.id) as count
FROM models m
LEFT JOIN model_properties mp ON mp.model_id = m.id
${where}
`);
const total = totalStmt.get(...params) as { count: number };
const listStmt = db.prepare(`
SELECT DISTINCT
m.*,
b.name AS brand_name,
t.name AS type_name
FROM models m
LEFT JOIN brands b ON b.id = m.brand_id
LEFT JOIN model_types t ON t.id = m.type_id
LEFT JOIN model_properties mp ON mp.model_id = m.id
${where}
ORDER BY m.updated_at DESC, m.id DESC
LIMIT ? OFFSET ?
`);
const rows = listStmt.all(...params, query.pageSize, offset) as unknown as ModelRow[];
return {
total: total.count,
page: query.page,
pageSize: query.pageSize,
items: rows.map(modelPayload)
};
});
app.post("/api/models/upload", { preHandler: [requireAdmin] }, async (request, reply) => {
const parts = request.parts();
let folderId: number | undefined;
let thumbnail: string | null = null;
const properties: Record<string, string> = {};
let uploaded: { filename: string; buffer: Buffer } | undefined;
let modelName: string | undefined;
let brandName: string | undefined;
let typeName: string | undefined;
for await (const part of parts) {
if (part.type === "file") {
if (part.fieldname === "file") {
const chunks = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
uploaded = { filename: part.filename, buffer: Buffer.concat(chunks) };
}
continue;
}
if (part.fieldname === "folderId") folderId = Number(part.value);
if (part.fieldname === "name") modelName = String(part.value ?? "");
if (part.fieldname === "brandName") brandName = String(part.value ?? "");
if (part.fieldname === "typeName") typeName = String(part.value ?? "");
if (part.fieldname === "thumbnail") thumbnail = String(part.value || "") || null;
if (part.fieldname.startsWith("prop.")) {
properties[part.fieldname.slice(5)] = String(part.value ?? "");
}
}
if (!folderId || !uploaded) {
return reply.code(400).send({ message: "缺少目录或模型文件" });
}
assertGlbFilename(uploaded.filename);
const folder = getFolder(folderId);
if (!folder) {
return reply.code(404).send({ message: "目录不存在" });
}
const originalModelName = assertValidFolderName(modelName || normalizeModelName(uploaded.filename));
const storageFilename = `${normalizeModelName(uploaded.filename)}-${nanoid(8)}.glb`;
const objectKey = toStorageRelative(folder.path, storageFilename);
const exists = db.prepare("SELECT id FROM models WHERE folder_id = ? AND name = ?").get(folderId, originalModelName);
if (exists) {
return reply.code(409).send({ message: "当前目录下模型名称已存在" });
}
const stored = await storage.putObject(objectKey, uploaded.buffer);
try {
const brandId = findOrCreateDictionary("brands", brandName);
const typeId = findOrCreateDictionary("model_types", typeName);
const result = db.prepare(`
INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, storage_provider, file_size, thumbnail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, stored.provider, uploaded.buffer.length, thumbnail);
saveProperties(Number(result.lastInsertRowid), properties);
return reply.code(201).send({ id: result.lastInsertRowid });
} catch (error) {
await storage.deleteObject(stored.key);
throw error;
}
});
app.put("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
const body = updateSchema.parse(request.body);
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
if (!model) {
return reply.code(404).send({ message: "模型不存在" });
}
const name = assertValidFolderName(body.name);
try {
const brandId = findOrCreateDictionary("brands", body.brandName);
const typeId = findOrCreateDictionary("model_types", body.typeName);
db.prepare(`
UPDATE models
SET name = ?, brand_id = ?, type_id = ?, thumbnail = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(name, brandId, typeId, body.thumbnail ?? model.thumbnail, model.id);
if (body.properties) {
saveProperties(model.id, body.properties);
}
return { ok: true };
} catch {
return reply.code(409).send({ message: "当前目录下模型名称已存在" });
}
});
app.put("/api/models/:id/thumbnail", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
const body = thumbnailSchema.parse(request.body);
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
if (!model) {
return reply.code(404).send({ message: "模型不存在" });
}
const image = dataImageToBuffer(body.thumbnail);
const stored = await storage.putObject(`thumbnails/model-${model.id}-${nanoid(8)}.${image.ext}`, image.buffer);
try {
db.prepare(`
UPDATE models
SET thumbnail = NULL, thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(stored.key, stored.provider, model.id);
if (model.thumbnail_path && model.thumbnail_provider) {
await deleteStoredObject(model.thumbnail_path, model.thumbnail_provider);
}
return { ok: true, thumbnail_url: publicObjectUrl(stored.key, stored.provider) };
} catch (error) {
await storage.deleteObject(stored.key);
throw error;
}
});
app.delete("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
if (!model) {
return reply.code(404).send({ message: "模型不存在" });
}
db.prepare("DELETE FROM models WHERE id = ?").run(model.id);
await deleteStoredObject(model.file_path, model.storage_provider);
if (model.thumbnail_path && model.thumbnail_provider) {
await deleteStoredObject(model.thumbnail_path, model.thumbnail_provider);
}
return { ok: true };
});
}

138
server/src/storage.ts Normal file
View File

@@ -0,0 +1,138 @@
import fs from "node:fs/promises";
import path from "node:path";
import COS from "cos-nodejs-sdk-v5";
import { config } from "./config.js";
import { ensureDir, removeIfExists, safeStoragePath } from "./utils.js";
export type StorageProvider = "local" | "cos";
export type StoredObject = {
key: string;
provider: StorageProvider;
};
type StorageService = {
provider: StorageProvider;
putObject(key: string, buffer: Buffer): Promise<StoredObject>;
deleteObject(key: string): Promise<void>;
publicUrl(key: string): string;
};
function normalizeKey(key: string) {
return key.replace(/\\/g, "/").replace(/^\/+/, "");
}
function joinUrl(base: string, key: string) {
return `${base.replace(/\/+$/g, "")}/${normalizeKey(key).split("/").map(encodeURIComponent).join("/")}`;
}
class LocalStorageService implements StorageService {
provider: StorageProvider = "local";
async putObject(key: string, buffer: Buffer) {
const normalized = normalizeKey(key);
const fullPath = safeStoragePath(normalized);
await ensureDir(path.dirname(fullPath));
await fs.writeFile(fullPath, buffer);
return { key: normalized, provider: this.provider };
}
async deleteObject(key: string) {
await removeIfExists(safeStoragePath(normalizeKey(key)));
}
publicUrl(key: string) {
const normalized = normalizeKey(key);
if (config.publicBaseUrl) {
return joinUrl(`${config.publicBaseUrl}/storage`, normalized);
}
return `/storage/${normalized}`;
}
}
class CosStorageService implements StorageService {
provider: StorageProvider = "cos";
private cos: COS;
constructor() {
const missing = [
["COS_SECRET_ID", config.cos.secretId],
["COS_SECRET_KEY", config.cos.secretKey],
["COS_BUCKET", config.cos.bucket],
["COS_REGION", config.cos.region],
["CDN_BASE_URL", config.cdnBaseUrl]
].filter(([, value]) => !value).map(([key]) => key);
if (missing.length > 0) {
throw new Error(`COS 存储配置缺失:${missing.join(", ")}`);
}
this.cos = new COS({
SecretId: config.cos.secretId,
SecretKey: config.cos.secretKey
});
}
async putObject(key: string, buffer: Buffer) {
const objectKey = normalizeKey(config.cos.prefix ? `${config.cos.prefix}/${key}` : key);
await new Promise<void>((resolve, reject) => {
this.cos.putObject({
Bucket: config.cos.bucket,
Region: config.cos.region,
Key: objectKey,
Body: buffer
}, (error) => {
if (error) reject(error);
else resolve();
});
});
return { key: objectKey, provider: this.provider };
}
async deleteObject(key: string) {
await new Promise<void>((resolve, reject) => {
this.cos.deleteObject({
Bucket: config.cos.bucket,
Region: config.cos.region,
Key: normalizeKey(key)
}, (error) => {
if (error) reject(error);
else resolve();
});
});
}
publicUrl(key: string) {
return joinUrl(config.cdnBaseUrl, key);
}
}
export const storage = config.storageDriver === "cos"
? new CosStorageService()
: new LocalStorageService();
const localStorage = storage.provider === "local" ? storage : new LocalStorageService();
export function publicObjectUrl(key: string, provider: StorageProvider) {
if (provider === "cos") {
if (!config.cdnBaseUrl) return normalizeKey(key);
return joinUrl(config.cdnBaseUrl, key);
}
return localStorage.publicUrl(key);
}
export async function deleteStoredObject(key: string, provider: StorageProvider) {
if (provider === storage.provider) {
await storage.deleteObject(key);
return;
}
if (provider === "local") {
await localStorage.deleteObject(key);
}
}
export function storageStatus() {
return {
driver: storage.provider,
cdnEnabled: Boolean(config.cdnBaseUrl),
cosConfigured: Boolean(config.cos.bucket && config.cos.region && config.cos.secretId && config.cos.secretKey)
};
}

51
server/src/types.ts Normal file
View File

@@ -0,0 +1,51 @@
export type UserRole = "admin" | "user";
export interface AuthUser {
id: number;
username: string;
role: UserRole;
}
export interface UserRow {
id: number;
username: string;
password_hash: string;
role: UserRole;
enabled: number;
expires_at: string | null;
created_at: string;
updated_at: string;
}
export interface FolderRow {
id: number;
parent_id: number | null;
name: string;
path: string;
created_at: string;
updated_at: string;
}
export interface ModelRow {
id: number;
folder_id: number;
brand_id: number | null;
type_id: number | null;
name: string;
original_filename: string;
file_path: string;
storage_provider: "local" | "cos";
file_size: number;
thumbnail: string | null;
thumbnail_path: string | null;
thumbnail_provider: "local" | "cos" | null;
brand_name?: string | null;
type_name?: string | null;
created_at: string;
updated_at: string;
}
export interface DictionaryRow {
id: number;
name: string;
}

55
server/src/utils.ts Normal file
View File

@@ -0,0 +1,55 @@
import fs from "node:fs/promises";
import path from "node:path";
import { config } from "./config.js";
const invalidNamePattern = /[\\/:*?"<>|]/;
export function assertValidFolderName(name: string) {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("名称不能为空");
}
if (invalidNamePattern.test(trimmed)) {
throw new Error('名称不能包含 \\ / : * ? " < > |');
}
if (trimmed === "." || trimmed === "..") {
throw new Error("名称不合法");
}
return trimmed;
}
export function assertGlbFilename(filename: string) {
if (path.extname(filename).toLowerCase() !== ".glb") {
throw new Error("当前阶段只允许上传 .glb 模型");
}
}
export function safeStoragePath(relativePath: string) {
const fullPath = path.resolve(config.storageDir, relativePath);
const root = path.resolve(config.storageDir);
if (!fullPath.startsWith(root)) {
throw new Error("文件路径不合法");
}
return fullPath;
}
export async function ensureDir(dirPath: string) {
await fs.mkdir(dirPath, { recursive: true });
}
export async function removeIfExists(targetPath: string) {
await fs.rm(targetPath, { recursive: true, force: true });
}
export function toStorageRelative(folderPath: string, filename: string) {
return path.join("models", folderPath, filename);
}
export function toFolderStorageRelative(folderPath: string) {
return path.join("models", folderPath);
}
export function normalizeModelName(filename: string) {
return path.basename(filename, path.extname(filename)).trim();
}

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DMT 模型库</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "dmt-model-library-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"jquery": "^3.7.1",
"jstree": "^3.3.17",
"three": "^0.184.0",
"w2ui": "^2.0.0"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"@types/jstree": "^3.3.46",
"@types/three": "^0.184.1",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View 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
View 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
View 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;
}
}

View 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
};

View 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
View 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);
}

View File

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

View File

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

View File

@@ -0,0 +1,359 @@
import { api } from "../../../services/api";
import { renderPagination } from "../../../components/pagination";
import { getCurrentUser } from "../../../services/authState";
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
import { appState } from "../appState";
type UploadFormState = {
file: File | null;
};
export function bindModelActions() {
const isAdmin = getCurrentUser()?.role === "admin";
if (isAdmin) {
document.querySelector("#manageUsersBtn")?.addEventListener("click", async () => {
try {
const { openUserManager } = await import("./users");
await openUserManager();
} catch (error) {
notifyError(error);
}
});
document.querySelector("#manageDictionariesBtn")?.addEventListener("click", async () => {
try {
const { openDictionaryManager } = await import("./dictionaries");
await openDictionaryManager(loadModels);
} catch (error) {
notifyError(error);
}
});
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
try {
await openUploadModelDialog();
} catch (error) {
notifyError(error);
}
});
}
document.querySelector<HTMLSelectElement>("#brandFilter")!.addEventListener("change", async (event) => {
appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value;
appState.page = 1;
await loadModels();
});
document.querySelector<HTMLSelectElement>("#typeFilter")!.addEventListener("change", async (event) => {
appState.filters.typeId = (event.currentTarget as HTMLSelectElement).value;
appState.page = 1;
await loadModels();
});
document.querySelector<HTMLInputElement>("#keywordFilter")!.addEventListener("keydown", async (event) => {
if (event.key !== "Enter") return;
appState.filters.keyword = (event.currentTarget as HTMLInputElement).value.trim();
appState.page = 1;
await loadModels();
});
document.querySelector<HTMLInputElement>("#keywordFilter")!.addEventListener("change", async (event) => {
appState.filters.keyword = (event.currentTarget as HTMLInputElement).value.trim();
appState.page = 1;
await loadModels();
});
document.querySelector("#resetFilterBtn")!.addEventListener("click", async () => {
appState.filters.brandId = "";
appState.filters.typeId = "";
appState.filters.keyword = "";
document.querySelector<HTMLSelectElement>("#brandFilter")!.value = "";
document.querySelector<HTMLSelectElement>("#typeFilter")!.value = "";
document.querySelector<HTMLInputElement>("#keywordFilter")!.value = "";
appState.page = 1;
await loadModels();
});
}
export async function loadModels() {
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
if (!grid || !appState.selectedFolderId) return;
await loadDictionaries();
document.querySelector("#folderCrumb")!.textContent = appState.selectedFolderName || "模型库";
const params = new URLSearchParams({
folderId: String(appState.selectedFolderId),
page: String(appState.page),
pageSize: String(appState.pageSize)
});
if (appState.filters.brandId) params.set("brandId", appState.filters.brandId);
if (appState.filters.typeId) params.set("typeId", appState.filters.typeId);
if (appState.filters.keyword) params.set("keyword", appState.filters.keyword);
const result = await api<ModelListResponse>(`/api/models?${params.toString()}`);
if (result.items.length === 0 && appState.page > 1) {
appState.page -= 1;
return loadModels();
}
document.querySelector("#modelCount")!.textContent = `${result.total} 个模型`;
renderPagination({
container: document.querySelector<HTMLElement>("#modelPagination")!,
total: result.total,
page: appState.page,
pageSize: appState.pageSize,
onChange: async (page, pageSize) => {
appState.page = page;
appState.pageSize = pageSize;
await loadModels();
}
});
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => {
const model = result.items.find((item) => item.id === Number(button.dataset.id));
if (model) {
button.addEventListener("click", async () => {
const { openModelPreview } = await import("./preview");
openModelPreview(model, loadModels);
});
}
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => {
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
button.addEventListener("click", () => {
notify("导入功能预留,后续接入当前模型的导入逻辑");
});
});
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
});
}
async function loadDictionaries() {
const result = await api<DictionaryResponse>("/api/dictionaries");
appState.brands = result.brands;
appState.types = result.types;
syncDictionarySelect("#brandFilter", result.brands, "全部品牌", appState.filters.brandId);
syncDictionarySelect("#typeFilter", result.types, "全部类型", appState.filters.typeId);
}
function syncDictionarySelect(selector: string, items: { id: number; name: string }[], emptyText: string, value: string) {
const select = document.querySelector<HTMLSelectElement>(selector);
if (!select) return;
const current = select.value || value;
select.innerHTML = `<option value="">${emptyText}</option>` + items
.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`)
.join("");
select.value = current;
}
function renderModelCard(item: ModelItem) {
const isAdmin = getCurrentUser()?.role === "admin";
const prop = item.properties ?? {};
const thumb = item.thumbnail_url
? `<img src="${item.thumbnail_url}" alt="" />`
: `<div class="thumb-placeholder">GLB</div>`;
return `
<article class="model-card" data-model-id="${item.id}">
<div class="model-info">
<strong>${escapeHtml(item.name)}</strong>
</div>
<div class="thumb">
${thumb}
<div class="thumb-actions">
<button data-action="preview" data-id="${item.id}">预览</button>
${isAdmin ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
<button data-action="import" data-id="${item.id}">导入</button>
${isAdmin ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
</div>
</div>
<dl>
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</dd></div>
</dl>
</article>
`;
}
async function openUploadModelDialog() {
if (!appState.selectedFolderId) {
notify("请先选择目录");
return;
}
const state: UploadFormState = { file: null };
await loadDictionaries();
const result = await formDialog<boolean>({
title: "增加模型",
width: 560,
height: 520,
body: `
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
<label>
<span>模型文件</span>
<div id="modelDropZone" class="drop-zone">
<input id="modelUploadFile" type="file" accept=".glb" />
<strong>选择模型</strong>
<em>或拖拽 .glb 模型到这里</em>
<small id="selectedFileName">未选择文件</small>
</div>
</label>
<label>
<span>模型名称</span>
<input id="modelUploadName" name="name" placeholder="选择文件后自动填入" />
</label>
<div class="popup-form-grid">
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
<label><span>型号</span><input name="model" /></label>
<label><span>价钱</span><input name="price" /></label>
<label><span>重量</span><input name="weight" /></label>
</div>
${dictionaryDatalistHtml()}
</form>
`,
onOpen: () => bindUploadDialogEvents(state),
onSubmit: async () => {
if (!state.file) {
throw new Error("请选择 .glb 模型文件");
}
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
if (!name) {
throw new Error("模型名称不能为空");
}
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
const payload = new FormData();
payload.set("folderId", String(appState.selectedFolderId));
payload.set("name", name);
payload.set("brandName", String(form.get("brandName") ?? ""));
payload.set("typeName", String(form.get("typeName") ?? ""));
payload.set("file", state.file);
for (const key of ["model", "price", "weight"]) {
payload.set(`prop.${key}`, String(form.get(key) ?? ""));
}
await api("/api/models/upload", {
method: "POST",
body: payload
});
return true;
}
});
if (result) {
await loadModels();
}
}
function bindUploadDialogEvents(state: UploadFormState) {
const dropZone = document.querySelector<HTMLDivElement>("#modelDropZone")!;
const fileInput = document.querySelector<HTMLInputElement>("#modelUploadFile")!;
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
const selectFile = (file: File) => {
if (!file.name.toLowerCase().endsWith(".glb")) {
notify("当前阶段只允许上传 .glb 模型");
return;
}
state.file = file;
selectedFileName.textContent = file.name;
if (!nameInput.value.trim()) {
nameInput.value = modelNameFromFile(file.name);
}
};
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) selectFile(file);
});
dropZone.addEventListener("click", (event) => {
if (event.target !== fileInput) fileInput.click();
});
dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
dropZone.classList.add("is-dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("is-dragover");
});
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragover");
const file = event.dataTransfer?.files?.[0];
if (file) selectFile(file);
});
}
async function editModel(id: number) {
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
const oldName = card?.querySelector("strong")?.textContent ?? "";
await loadDictionaries();
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string; price: string; weight: string }>({
title: "编辑模型",
width: 520,
height: 390,
body: `
<form id="modelEditPopupForm" class="popup-form">
<label><span>模型名称</span><input name="name" value="${escapeHtml(oldName)}" /></label>
<div class="popup-form-grid">
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
<label><span>价钱</span><input name="price" value="${escapeHtml(card?.querySelector("dl div:nth-child(4) dd")?.textContent ?? "")}" /></label>
<label><span>重量</span><input name="weight" value="${escapeHtml(card?.querySelector("dl div:nth-child(5) dd")?.textContent ?? "")}" /></label>
</div>
${dictionaryDatalistHtml()}
</form>
`,
onSubmit: () => {
const form = new FormData(document.querySelector<HTMLFormElement>("#modelEditPopupForm")!);
const name = String(form.get("name") ?? "").trim();
if (!name) {
throw new Error("模型名称不能为空");
}
return {
name,
brandName: String(form.get("brandName") ?? ""),
typeName: String(form.get("typeName") ?? ""),
model: String(form.get("model") ?? ""),
price: String(form.get("price") ?? ""),
weight: String(form.get("weight") ?? "")
};
}
});
if (!result) return;
await api(`/api/models/${id}`, {
method: "PUT",
body: JSON.stringify({
name: result.name,
brandName: result.brandName,
typeName: result.typeName,
properties: {
model: result.model,
price: result.price,
weight: result.weight
}
})
});
await loadModels();
}
function dictionaryDatalistHtml() {
return `
<datalist id="brandOptions">
${appState.brands.map((item) => `<option value="${escapeHtml(item.name)}"></option>`).join("")}
</datalist>
<datalist id="typeOptions">
${appState.types.map((item) => `<option value="${escapeHtml(item.name)}"></option>`).join("")}
</datalist>
`;
}
async function deleteModel(id: number) {
const confirmed = await confirmDialog("确认删除该模型?");
if (!confirmed) return;
await api(`/api/models/${id}`, { method: "DELETE" });
await loadModels();
}

View File

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

View File

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

View 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;
}

View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,20 @@
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[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(/\.[^/.]+$/, "");
}

16
web/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["src"]
}

12
web/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5174,
strictPort: true,
proxy: {
"/api": "http://localhost:3001",
"/storage": "http://localhost:3001"
}
}
});

18
设计文档.md Normal file
View File

@@ -0,0 +1,18 @@
# 模型库后台服务及web界面
## 技术列表
1. 后端服务nodejs + typescript
2. 前台界面vite + vanilla + w2ui + jstree + typescript
3. 数据库采用sqlite
## 功能
1. 登录界面注册登录功能图片验证jwt
2. 主界面分两个区域可以采用w2ui的Layout可拖拽调整宽度左侧目录树采用jstree右侧模型列表,又缩略图,模型名字,分页显示
3. 目录可以新建,编辑名称,删除,删除的时候提示会删除目录下所有数据,名称要符合文件夹的命名规范
4. 模型文件存储在后台文件夹中,目录结构及名称要和模型树保持一致,完整路径存储在数据库
5. 模型上传功能,上传要检查是否重名,
6. 模型编辑名称功能,模型预览功能
7. 模型要配置额外的属性比如品牌/型号/价钱/重量等,可以扩展,存储到数据库
8. 缩略图也要存储在数据库中
9. 模型预览采用three.js只允许glb模型预览其他格式暂时不允许
10. 用户管理界面(账号禁用启用,时间授权),权限管理(允许用户访问那些文件夹中的模型)界面