initial commit
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.idea/
|
||||
*.log
|
||||
server/data/*.db
|
||||
server/data/*.db-*
|
||||
server/storage/
|
||||
20
README.md
Normal file
20
README.md
Normal 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
6270
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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
29
server/package.json
Normal 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
227
server/src/auth.ts
Normal 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
28
server/src/config.ts
Normal 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
119
server/src/db.ts
Normal 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
154
server/src/folders.ts
Normal 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
52
server/src/index.ts
Normal 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
340
server/src/models.ts
Normal 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
138
server/src/storage.ts
Normal 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
51
server/src/types.ts
Normal 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
55
server/src/utils.ts
Normal 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
13
server/tsconfig.json
Normal 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
12
web/index.html
Normal 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
25
web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
84
web/src/components/pagination.ts
Normal file
84
web/src/components/pagination.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type PaginationOptions = {
|
||||
container: HTMLElement;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageSizes?: number[];
|
||||
onChange: (page: number, pageSize: number) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function clampPage(page: number, totalPages: number) {
|
||||
return Math.min(Math.max(page, 1), totalPages);
|
||||
}
|
||||
|
||||
function pageItems(page: number, totalPages: number) {
|
||||
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]);
|
||||
if (page <= 3) {
|
||||
pages.add(2);
|
||||
pages.add(3);
|
||||
}
|
||||
if (page >= totalPages - 2) {
|
||||
pages.add(totalPages - 1);
|
||||
pages.add(totalPages - 2);
|
||||
}
|
||||
const sorted = [...pages].filter((item) => item >= 1 && item <= totalPages).sort((a, b) => a - b);
|
||||
const result: Array<number | "..."> = [];
|
||||
for (const item of sorted) {
|
||||
const previous = result[result.length - 1];
|
||||
if (typeof previous === "number" && item - previous > 1) {
|
||||
result.push("...");
|
||||
}
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderPagination(options: PaginationOptions) {
|
||||
const totalPages = Math.max(Math.ceil(options.total / options.pageSize), 1);
|
||||
const page = clampPage(options.page, totalPages);
|
||||
const pageSizes = options.pageSizes ?? [12, 24, 48, 96];
|
||||
const start = options.total === 0 ? 0 : (page - 1) * options.pageSize + 1;
|
||||
const end = Math.min(page * options.pageSize, options.total);
|
||||
|
||||
options.container.innerHTML = `
|
||||
<div class="pagination-total">共 ${options.total} 条</div>
|
||||
<div class="pagination-range">${start}-${end}</div>
|
||||
<select class="pagination-size" aria-label="每页条数">
|
||||
${pageSizes.map((size) => `<option value="${size}" ${size === options.pageSize ? "selected" : ""}>${size} 条/页</option>`).join("")}
|
||||
</select>
|
||||
<button class="pagination-btn" type="button" data-page="${page - 1}" ${page <= 1 ? "disabled" : ""}>上一页</button>
|
||||
<div class="pagination-pages">
|
||||
${pageItems(page, totalPages).map((item) => item === "..."
|
||||
? `<span class="pagination-ellipsis">...</span>`
|
||||
: `<button class="pagination-btn pagination-page ${item === page ? "is-active" : ""}" type="button" data-page="${item}">${item}</button>`).join("")}
|
||||
</div>
|
||||
<button class="pagination-btn" type="button" data-page="${page + 1}" ${page >= totalPages ? "disabled" : ""}>下一页</button>
|
||||
<label class="pagination-jumper">
|
||||
<span>前往</span>
|
||||
<input type="number" min="1" max="${totalPages}" value="${page}" />
|
||||
<span>页</span>
|
||||
</label>
|
||||
`;
|
||||
|
||||
options.container.querySelector<HTMLSelectElement>(".pagination-size")?.addEventListener("change", async (event) => {
|
||||
const nextPageSize = Number((event.currentTarget as HTMLSelectElement).value);
|
||||
await options.onChange(1, nextPageSize);
|
||||
});
|
||||
|
||||
options.container.querySelectorAll<HTMLButtonElement>("[data-page]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const nextPage = clampPage(Number(button.dataset.page), totalPages);
|
||||
if (nextPage === page) return;
|
||||
await options.onChange(nextPage, options.pageSize);
|
||||
});
|
||||
});
|
||||
|
||||
const jumper = options.container.querySelector<HTMLInputElement>(".pagination-jumper input");
|
||||
jumper?.addEventListener("keydown", async (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
await options.onChange(clampPage(Number(jumper.value || 1), totalPages), options.pageSize);
|
||||
});
|
||||
jumper?.addEventListener("change", async () => {
|
||||
await options.onChange(clampPage(Number(jumper.value || 1), totalPages), options.pageSize);
|
||||
});
|
||||
}
|
||||
25
web/src/main.ts
Normal file
25
web/src/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import "jstree/dist/themes/default/style.min.css";
|
||||
import "w2ui/w2ui-2.0.min.css";
|
||||
import "./styles/base.css";
|
||||
import { api } from "./services/api";
|
||||
import { getToken, setCurrentUser, clearSession } from "./services/authState";
|
||||
import type { AuthUser } from "./types";
|
||||
import { renderLogin } from "./pages/login/login";
|
||||
import { renderApp } from "./pages/app/layout";
|
||||
|
||||
async function bootstrap() {
|
||||
if (!getToken()) {
|
||||
renderLogin(renderApp);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api<{ user: AuthUser }>("/api/auth/me");
|
||||
setCurrentUser(result.user);
|
||||
await renderApp();
|
||||
} catch {
|
||||
clearSession();
|
||||
renderLogin(renderApp);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
340
web/src/pages/app/app.css
Normal file
340
web/src/pages/app/app.css
Normal file
@@ -0,0 +1,340 @@
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 42px 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.topbar strong {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.topbar span {
|
||||
color: #5d6d7e;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree-pane {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
#folderTree {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.content-pane {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.content-toolbar h2 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content-toolbar p {
|
||||
margin: 0;
|
||||
color: #647586;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 160px) minmax(120px, 160px) minmax(200px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafb;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.filter-toolbar label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.filter-toolbar span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-toolbar select {
|
||||
width: 100%;
|
||||
border: 1px solid #c9d3dd;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.model-grid {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7e0e8;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
linear-gradient(45deg, #edf2f5 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #edf2f5 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #edf2f5 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #edf2f5 75%),
|
||||
#f8fafb;
|
||||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||||
background-size: 12px 12px;
|
||||
border: 1px solid #d3dde5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.thumb-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
background: rgba(22, 33, 44, 0.68);
|
||||
opacity: 0;
|
||||
transform: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.14s ease;
|
||||
}
|
||||
|
||||
.thumb:hover .thumb-actions,
|
||||
.thumb:focus-within .thumb-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.thumb-actions button {
|
||||
width: min(86px, 80%);
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
color: #456171;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.model-info strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid #e3e9ee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
dl div:nth-child(odd) {
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
dl div:nth-child(even) {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #6b7b8b;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
min-height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
color: #405469;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-total,
|
||||
.pagination-range {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-size {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 4px 26px 4px 8px;
|
||||
}
|
||||
|
||||
.pagination-pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 3px 8px;
|
||||
border-color: transparent;
|
||||
background: #f6f9fb;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled),
|
||||
.pagination-page.is-active {
|
||||
border-color: #1f6f8b;
|
||||
background: #eaf4f7;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.pagination-page.is-active {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
min-width: 20px;
|
||||
color: #8b99a7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-jumper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-jumper input {
|
||||
width: 46px;
|
||||
min-height: 28px;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: #667789;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.content-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.keyword-filter {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
16
web/src/pages/app/appState.ts
Normal file
16
web/src/pages/app/appState.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { DictionaryItem, Folder } from "../../types";
|
||||
|
||||
export const appState = {
|
||||
selectedFolderId: null as number | null,
|
||||
selectedFolderName: "",
|
||||
folders: [] as Folder[],
|
||||
brands: [] as DictionaryItem[],
|
||||
types: [] as DictionaryItem[],
|
||||
filters: {
|
||||
brandId: "",
|
||||
typeId: "",
|
||||
keyword: ""
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 12
|
||||
};
|
||||
687
web/src/pages/app/dialogs.css
Normal file
687
web/src/pages/app/dialogs.css
Normal file
@@ -0,0 +1,687 @@
|
||||
.popup-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.popup-form label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.popup-form label span {
|
||||
color: #405469;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.popup-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-form-grid label:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
position: relative;
|
||||
min-height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #9fb1c0;
|
||||
border-radius: 8px;
|
||||
background: #f6f9fb;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone.is-dragover {
|
||||
border-color: #1f6f8b;
|
||||
background: #eaf4f7;
|
||||
}
|
||||
|
||||
.drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drop-zone strong {
|
||||
color: #1f6f8b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drop-zone em {
|
||||
color: #667789;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.drop-zone small {
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: #f4f7f9;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-process-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
border-right: 1px solid #d8e0e8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.preview-process-section {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.preview-process-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-process-header span {
|
||||
color: #7a8997;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-process-tree {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.preview-model-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e3e9ee;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.preview-model-meta span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-model-meta strong {
|
||||
color: #405469;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-process-tree li {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.preview-process-tree button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-process-tree li.is-active button {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.preview-process-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-scene-section {
|
||||
border-top: 1px solid #d8e0e8;
|
||||
}
|
||||
|
||||
.preview-scene-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.preview-scene-body > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.preview-switch input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid span {
|
||||
color: #647586;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview-basepoint-grid input {
|
||||
min-height: 26px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.preview-canvas-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #647586;
|
||||
}
|
||||
|
||||
.dictionary-manager {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f3f6f8;
|
||||
}
|
||||
|
||||
.dictionary-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.dictionary-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafb 100%);
|
||||
}
|
||||
|
||||
.dictionary-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.dictionary-title i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #1f6f8b;
|
||||
box-shadow: 0 0 0 3px rgba(31, 111, 139, 0.12);
|
||||
}
|
||||
|
||||
.dictionary-title strong {
|
||||
color: #243447;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dictionary-count {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #d9e4ea;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #5f7182;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dictionary-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.dictionary-editor input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dictionary-save-btn,
|
||||
.dictionary-cancel-btn {
|
||||
min-width: 48px;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
.dictionary-list-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #f5f8fa;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
}
|
||||
|
||||
.dictionary-list-head span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dictionary-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 3px 0 6px;
|
||||
}
|
||||
|
||||
.dictionary-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid #eef2f5;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
.dictionary-item:nth-child(even) {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.dictionary-item:hover {
|
||||
background: #edf6f8;
|
||||
}
|
||||
|
||||
.dictionary-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #263746;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dictionary-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dictionary-edit-btn,
|
||||
.dictionary-delete-btn {
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.dictionary-edit-btn:hover {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
}
|
||||
|
||||
.danger-text-btn {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.danger-text-btn:hover {
|
||||
border-color: #f0b8b2;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.dictionary-empty {
|
||||
margin: 8px;
|
||||
padding: 42px 8px;
|
||||
border: 1px dashed #cdd8e1;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfd;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dictionary-manager {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.user-manager {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #f3f6f8;
|
||||
}
|
||||
|
||||
.user-manager-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.user-manager-head div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-manager-head strong {
|
||||
color: #243447;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-manager-head span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-manager-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.user-table-head,
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 1fr) 78px 68px 88px 88px 98px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-table-head {
|
||||
padding: 7px 12px;
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #f5f8fa;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
}
|
||||
|
||||
.user-table-head span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-table-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 3px 0 6px;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
min-height: 38px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid #eef2f5;
|
||||
}
|
||||
|
||||
.user-row:nth-child(even) {
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.user-row:hover {
|
||||
background: #edf6f8;
|
||||
}
|
||||
|
||||
.user-row > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.user-name strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #263746;
|
||||
}
|
||||
|
||||
.user-name em {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f6;
|
||||
color: #647586;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.role-badge,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
border: 1px solid #d9e4ea;
|
||||
background: #f8fafb;
|
||||
color: #4d5f70;
|
||||
}
|
||||
|
||||
.role-badge.is-admin {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.status-badge.is-enabled {
|
||||
border: 1px solid #b9dec6;
|
||||
background: #edf8f0;
|
||||
color: #246b38;
|
||||
}
|
||||
|
||||
.status-badge.is-disabled {
|
||||
border: 1px solid #d8dde3;
|
||||
background: #f0f2f4;
|
||||
color: #6a7682;
|
||||
}
|
||||
|
||||
.status-badge.is-expired {
|
||||
border: 1px solid #f0c0b8;
|
||||
background: #fff1f0;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.user-row-actions button {
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.user-row-actions button:hover {
|
||||
border-color: #b8d4df;
|
||||
background: #eaf4f7;
|
||||
}
|
||||
|
||||
.user-row-actions .danger-text-btn {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.user-empty {
|
||||
margin: 8px;
|
||||
padding: 48px 8px;
|
||||
border: 1px dashed #cdd8e1;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfd;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-editor-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
border: 1px solid #dbe3ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(28, 39, 51, 0.04);
|
||||
}
|
||||
|
||||
.user-editor-title {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e6edf2;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafb 100%);
|
||||
}
|
||||
|
||||
.user-editor-title strong {
|
||||
color: #243447;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-editor-title span {
|
||||
color: #647586;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-editor-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 5px;
|
||||
padding: 24px;
|
||||
color: #7a8997;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-editor-empty strong {
|
||||
color: #405469;
|
||||
}
|
||||
|
||||
.user-editor-form {
|
||||
align-content: start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-editor-form select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.user-manager-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-manager-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.user-table-head,
|
||||
.user-row {
|
||||
min-width: 720px;
|
||||
}
|
||||
}
|
||||
109
web/src/pages/app/layout.ts
Normal file
109
web/src/pages/app/layout.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { w2layout, w2ui } from "w2ui";
|
||||
import { getCurrentUser, clearSession } from "../../services/authState";
|
||||
import { loadFolders } from "./modules/folders";
|
||||
import { bindModelActions } from "./modules/models";
|
||||
import { renderLogin } from "../login/login";
|
||||
import "./app.css";
|
||||
import "./dialogs.css";
|
||||
|
||||
export async function renderApp() {
|
||||
const currentUser = getCurrentUser();
|
||||
const isAdmin = currentUser?.role === "admin";
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<strong>DMT 模型库</strong>
|
||||
<span id="folderCrumb">未选择目录</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span>${currentUser?.username ?? ""}</span>
|
||||
<button id="logoutBtn" class="icon-text-btn">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
<section id="workspaceLayout" class="workspace"></section>
|
||||
<template id="treePanelTemplate">
|
||||
<aside class="tree-pane">
|
||||
<div id="folderTree"></div>
|
||||
</aside>
|
||||
</template>
|
||||
<template id="modelPanelTemplate">
|
||||
<main class="content-pane">
|
||||
<div class="content-toolbar">
|
||||
<div>
|
||||
<h2>模型列表</h2>
|
||||
<p id="modelCount">0 个模型</p>
|
||||
</div>
|
||||
<div class="content-actions">
|
||||
${isAdmin ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
|
||||
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
|
||||
${isAdmin ? `<button id="addModelBtn" class="primary-btn" type="button">增加模型</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-toolbar">
|
||||
<label>
|
||||
<span>品牌</span>
|
||||
<select id="brandFilter">
|
||||
<option value="">全部品牌</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>类型</span>
|
||||
<select id="typeFilter">
|
||||
<option value="">全部类型</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="keyword-filter">
|
||||
<span>关键字</span>
|
||||
<input id="keywordFilter" placeholder="模型名 / 文件名 / 属性" />
|
||||
</label>
|
||||
<button id="resetFilterBtn" type="button">重置</button>
|
||||
</div>
|
||||
<div id="modelGrid" class="model-grid"></div>
|
||||
<footer id="modelPagination" class="pagination"></footer>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderWorkspaceLayout();
|
||||
|
||||
document.querySelector("#logoutBtn")!.addEventListener("click", () => {
|
||||
clearSession();
|
||||
renderLogin(renderApp);
|
||||
});
|
||||
|
||||
bindModelActions();
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
function renderWorkspaceLayout() {
|
||||
const workspace = document.querySelector<HTMLDivElement>("#workspaceLayout")!;
|
||||
const treeTemplate = document.querySelector<HTMLTemplateElement>("#treePanelTemplate")!;
|
||||
const modelTemplate = document.querySelector<HTMLTemplateElement>("#modelPanelTemplate")!;
|
||||
const existingLayout = w2ui.modelLibraryLayout;
|
||||
if (existingLayout?.destroy) {
|
||||
existingLayout.destroy();
|
||||
}
|
||||
const layout = new w2layout({
|
||||
name: "modelLibraryLayout",
|
||||
padding: 0,
|
||||
panels: [
|
||||
{
|
||||
type: "left",
|
||||
size: 300,
|
||||
minSize: 220,
|
||||
resizable: true,
|
||||
overflow: "hidden",
|
||||
html: treeTemplate.innerHTML
|
||||
},
|
||||
{
|
||||
type: "main",
|
||||
overflow: "hidden",
|
||||
html: modelTemplate.innerHTML
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
layout.render(workspace);
|
||||
}
|
||||
175
web/src/pages/app/modules/dictionaries.ts
Normal file
175
web/src/pages/app/modules/dictionaries.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { api } from "../../../services/api";
|
||||
import type { DictionaryItem, DictionaryResponse } from "../../../types";
|
||||
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
||||
import { escapeHtml } from "../../../utils/format";
|
||||
import { appState } from "../appState";
|
||||
|
||||
type DictionaryKind = "brands" | "types";
|
||||
|
||||
type DictionaryManageResult = {
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
const dictionaryLabels: Record<DictionaryKind, string> = {
|
||||
brands: "品牌",
|
||||
types: "类型"
|
||||
};
|
||||
|
||||
let changed = false;
|
||||
|
||||
export async function openDictionaryManager(onChanged?: () => Promise<void> | void) {
|
||||
changed = false;
|
||||
await refreshDictionaries();
|
||||
const result = await formDialog<DictionaryManageResult>({
|
||||
title: "品牌 / 类型维护",
|
||||
width: 680,
|
||||
height: 520,
|
||||
body: `
|
||||
<div class="dictionary-manager">
|
||||
${dictionaryPanelHtml("brands", "品牌")}
|
||||
${dictionaryPanelHtml("types", "类型")}
|
||||
</div>
|
||||
`,
|
||||
onOpen: bindDictionaryEvents,
|
||||
onSubmit: () => ({ changed })
|
||||
});
|
||||
|
||||
if (result?.changed) {
|
||||
await onChanged?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDictionaries() {
|
||||
const result = await api<DictionaryResponse>("/api/dictionaries");
|
||||
appState.brands = result.brands;
|
||||
appState.types = result.types;
|
||||
}
|
||||
|
||||
function dictionaryPanelHtml(kind: DictionaryKind, title: string) {
|
||||
const items = kind === "brands" ? appState.brands : appState.types;
|
||||
return `
|
||||
<section class="dictionary-panel" data-kind="${kind}">
|
||||
<header class="dictionary-panel-head">
|
||||
<div class="dictionary-title">
|
||||
<i aria-hidden="true"></i>
|
||||
<strong>${title}</strong>
|
||||
</div>
|
||||
<span class="dictionary-count">${items.length} 项</span>
|
||||
</header>
|
||||
<div class="dictionary-editor">
|
||||
<input data-role="name-input" data-kind="${kind}" placeholder="${title}名称" />
|
||||
<button class="primary-btn dictionary-save-btn" type="button" data-action="save" data-kind="${kind}">新增</button>
|
||||
<button class="ghost-btn dictionary-cancel-btn" type="button" data-action="cancel" data-kind="${kind}" hidden>取消</button>
|
||||
</div>
|
||||
<div class="dictionary-list-head">
|
||||
<span>名称</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div class="dictionary-list">
|
||||
${items.map((item) => dictionaryItemHtml(kind, item)).join("") || `<div class="dictionary-empty">暂无数据</div>`}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function dictionaryItemHtml(kind: DictionaryKind, item: DictionaryItem) {
|
||||
return `
|
||||
<div class="dictionary-item" data-kind="${kind}" data-id="${item.id}" data-name="${escapeHtml(item.name)}">
|
||||
<span class="dictionary-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>
|
||||
<div class="dictionary-row-actions">
|
||||
<button class="dictionary-edit-btn" type="button" data-action="edit" data-kind="${kind}" data-id="${item.id}">编辑</button>
|
||||
<button class="danger-text-btn dictionary-delete-btn" type="button" data-action="delete" data-kind="${kind}" data-id="${item.id}">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindDictionaryEvents() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".dictionary-manager button").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const kind = button.dataset.kind as DictionaryKind;
|
||||
const action = button.dataset.action;
|
||||
const id = Number(button.dataset.id);
|
||||
if (action === "save") await saveDictionaryItem(kind);
|
||||
if (action === "cancel") resetDictionaryEditor(kind);
|
||||
if (action === "edit") editDictionaryItem(kind, id);
|
||||
if (action === "delete") await deleteDictionaryItem(kind, id);
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDictionaryItem(kind: DictionaryKind) {
|
||||
const input = getDictionaryInput(kind);
|
||||
const name = input?.value.trim() ?? "";
|
||||
if (!name) {
|
||||
notify(`${dictionaryLabels[kind]}名称不能为空`);
|
||||
return;
|
||||
}
|
||||
const editingId = input?.dataset.editingId;
|
||||
await api(editingId ? `/api/dictionaries/${kind}/${editingId}` : `/api/dictionaries/${kind}`, {
|
||||
method: editingId ? "PUT" : "POST",
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
changed = true;
|
||||
notify(editingId ? "保存成功" : "新增成功");
|
||||
await rerenderDictionaryManager();
|
||||
}
|
||||
|
||||
function editDictionaryItem(kind: DictionaryKind, id: number) {
|
||||
const current = findDictionaryItem(kind, id);
|
||||
const input = getDictionaryInput(kind);
|
||||
if (!current || !input) return;
|
||||
input.value = current.name;
|
||||
input.dataset.editingId = String(id);
|
||||
const panel = document.querySelector<HTMLElement>(`.dictionary-panel[data-kind="${kind}"]`);
|
||||
const saveButton = panel?.querySelector<HTMLButtonElement>('button[data-action="save"]');
|
||||
const cancelButton = panel?.querySelector<HTMLButtonElement>('button[data-action="cancel"]');
|
||||
if (saveButton) saveButton.textContent = "保存";
|
||||
if (cancelButton) cancelButton.hidden = false;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function resetDictionaryEditor(kind: DictionaryKind) {
|
||||
const input = getDictionaryInput(kind);
|
||||
const panel = document.querySelector<HTMLElement>(`.dictionary-panel[data-kind="${kind}"]`);
|
||||
const saveButton = panel?.querySelector<HTMLButtonElement>('button[data-action="save"]');
|
||||
const cancelButton = panel?.querySelector<HTMLButtonElement>('button[data-action="cancel"]');
|
||||
if (input) {
|
||||
input.value = "";
|
||||
delete input.dataset.editingId;
|
||||
}
|
||||
if (saveButton) saveButton.textContent = "新增";
|
||||
if (cancelButton) cancelButton.hidden = true;
|
||||
}
|
||||
|
||||
function getDictionaryInput(kind: DictionaryKind) {
|
||||
return document.querySelector<HTMLInputElement>(`.dictionary-panel[data-kind="${kind}"] input[data-role="name-input"]`);
|
||||
}
|
||||
|
||||
async function deleteDictionaryItem(kind: DictionaryKind, id: number) {
|
||||
const current = findDictionaryItem(kind, id);
|
||||
if (!current) return;
|
||||
const confirmed = await confirmDialog(`删除 ${dictionaryLabels[kind]}「${current.name}」后,已引用该项的模型会清空该字段,是否继续?`);
|
||||
if (!confirmed) return;
|
||||
await api(`/api/dictionaries/${kind}/${id}`, { method: "DELETE" });
|
||||
changed = true;
|
||||
notify("删除成功");
|
||||
await rerenderDictionaryManager();
|
||||
}
|
||||
|
||||
function findDictionaryItem(kind: DictionaryKind, id: number) {
|
||||
const items = kind === "brands" ? appState.brands : appState.types;
|
||||
return items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
async function rerenderDictionaryManager() {
|
||||
await refreshDictionaries();
|
||||
const container = document.querySelector<HTMLDivElement>(".dictionary-manager");
|
||||
if (!container) return;
|
||||
container.innerHTML = `${dictionaryPanelHtml("brands", "品牌")}${dictionaryPanelHtml("types", "类型")}`;
|
||||
bindDictionaryEvents();
|
||||
}
|
||||
125
web/src/pages/app/modules/folders.ts
Normal file
125
web/src/pages/app/modules/folders.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import $ from "jquery";
|
||||
import "jstree";
|
||||
import { api } from "../../../services/api";
|
||||
import { getCurrentUser } from "../../../services/authState";
|
||||
import type { FolderTreeResponse } from "../../../types";
|
||||
import { confirmDialog, notify, notifyError, promptDialog } from "../../../ui/dialogs";
|
||||
import { appState } from "../appState";
|
||||
import { loadModels } from "./models";
|
||||
|
||||
type JsTreeNode = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function selectFolder(node: JsTreeNode) {
|
||||
appState.selectedFolderId = Number(node.id);
|
||||
appState.selectedFolderName = node.text;
|
||||
}
|
||||
|
||||
async function createFolder(parentId: number | null) {
|
||||
const name = await promptDialog({ title: "新建目录", label: "目录名称" });
|
||||
if (!name) return;
|
||||
await api("/api/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentId, name })
|
||||
});
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
async function renameFolder(folderId: number | null) {
|
||||
if (!folderId) return notify("请先选择目录");
|
||||
const current = appState.folders.find((folder) => folder.id === folderId);
|
||||
if (!current?.parent_id) return notify("根目录不能重命名");
|
||||
const name = await promptDialog({ title: "重命名目录", label: "目录名称", value: current.name });
|
||||
if (!name) return;
|
||||
await api(`/api/folders/${folderId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
async function deleteFolder(folderId: number | null) {
|
||||
if (!folderId) return notify("请先选择目录");
|
||||
const current = appState.folders.find((folder) => folder.id === folderId);
|
||||
if (!current?.parent_id) return notify("根目录不能删除");
|
||||
const confirmed = await confirmDialog("删除目录会删除目录下所有模型和子目录,是否继续?");
|
||||
if (!confirmed) return;
|
||||
await api(`/api/folders/${folderId}`, { method: "DELETE" });
|
||||
appState.selectedFolderId = null;
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
async function runFolderAction(action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFolders() {
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
const result = await api<FolderTreeResponse>("/api/folders");
|
||||
appState.folders = result.folders;
|
||||
const root = appState.folders.find((folder) => folder.parent_id === null);
|
||||
appState.selectedFolderId ??= root?.id ?? null;
|
||||
appState.selectedFolderName = appState.folders.find((folder) => folder.id === appState.selectedFolderId)?.name ?? "";
|
||||
|
||||
$("#folderTree").jstree("destroy");
|
||||
$("#folderTree").jstree({
|
||||
core: {
|
||||
data: result.tree,
|
||||
multiple: false
|
||||
},
|
||||
plugins: isAdmin ? ["contextmenu"] : [],
|
||||
contextmenu: {
|
||||
items(node: JsTreeNode) {
|
||||
const folderId = Number(node.id);
|
||||
const folder = appState.folders.find((item) => item.id === folderId);
|
||||
const isRoot = folder?.parent_id === null;
|
||||
return {
|
||||
create: {
|
||||
label: "新建目录",
|
||||
icon: "tree-menu-icon tree-menu-icon-add",
|
||||
action: () => runFolderAction(async () => {
|
||||
selectFolder(node);
|
||||
await createFolder(folderId);
|
||||
})
|
||||
},
|
||||
rename: {
|
||||
label: "重命名",
|
||||
icon: "tree-menu-icon tree-menu-icon-edit",
|
||||
_disabled: isRoot,
|
||||
action: () => runFolderAction(async () => {
|
||||
selectFolder(node);
|
||||
await renameFolder(folderId);
|
||||
})
|
||||
},
|
||||
remove: {
|
||||
label: "删除",
|
||||
icon: "tree-menu-icon tree-menu-icon-delete",
|
||||
_disabled: isRoot,
|
||||
action: () => runFolderAction(async () => {
|
||||
selectFolder(node);
|
||||
await deleteFolder(folderId);
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}).on("select_node.jstree", async (_event: JQuery.Event, data: { node: { id: string; text: string } }) => {
|
||||
appState.selectedFolderId = Number(data.node.id);
|
||||
appState.selectedFolderName = data.node.text;
|
||||
appState.page = 1;
|
||||
await loadModels();
|
||||
});
|
||||
|
||||
if (appState.selectedFolderId) {
|
||||
$("#folderTree").on("ready.jstree", () => {
|
||||
$("#folderTree").jstree(true).select_node(String(appState.selectedFolderId));
|
||||
});
|
||||
}
|
||||
await loadModels();
|
||||
}
|
||||
359
web/src/pages/app/modules/models.ts
Normal file
359
web/src/pages/app/modules/models.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { api } from "../../../services/api";
|
||||
import { renderPagination } from "../../../components/pagination";
|
||||
import { getCurrentUser } from "../../../services/authState";
|
||||
import type { DictionaryResponse, ModelItem, ModelListResponse } from "../../../types";
|
||||
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
||||
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
||||
import { appState } from "../appState";
|
||||
|
||||
type UploadFormState = {
|
||||
file: File | null;
|
||||
};
|
||||
|
||||
export function bindModelActions() {
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
if (isAdmin) {
|
||||
document.querySelector("#manageUsersBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const { openUserManager } = await import("./users");
|
||||
await openUserManager();
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#manageDictionariesBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const { openDictionaryManager } = await import("./dictionaries");
|
||||
await openDictionaryManager(loadModels);
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#addModelBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await openUploadModelDialog();
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector<HTMLSelectElement>("#brandFilter")!.addEventListener("change", async (event) => {
|
||||
appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value;
|
||||
appState.page = 1;
|
||||
await loadModels();
|
||||
});
|
||||
document.querySelector<HTMLSelectElement>("#typeFilter")!.addEventListener("change", async (event) => {
|
||||
appState.filters.typeId = (event.currentTarget as HTMLSelectElement).value;
|
||||
appState.page = 1;
|
||||
await loadModels();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#keywordFilter")!.addEventListener("keydown", async (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
appState.filters.keyword = (event.currentTarget as HTMLInputElement).value.trim();
|
||||
appState.page = 1;
|
||||
await loadModels();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#keywordFilter")!.addEventListener("change", async (event) => {
|
||||
appState.filters.keyword = (event.currentTarget as HTMLInputElement).value.trim();
|
||||
appState.page = 1;
|
||||
await loadModels();
|
||||
});
|
||||
document.querySelector("#resetFilterBtn")!.addEventListener("click", async () => {
|
||||
appState.filters.brandId = "";
|
||||
appState.filters.typeId = "";
|
||||
appState.filters.keyword = "";
|
||||
document.querySelector<HTMLSelectElement>("#brandFilter")!.value = "";
|
||||
document.querySelector<HTMLSelectElement>("#typeFilter")!.value = "";
|
||||
document.querySelector<HTMLInputElement>("#keywordFilter")!.value = "";
|
||||
appState.page = 1;
|
||||
await loadModels();
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadModels() {
|
||||
const grid = document.querySelector<HTMLDivElement>("#modelGrid");
|
||||
if (!grid || !appState.selectedFolderId) return;
|
||||
await loadDictionaries();
|
||||
document.querySelector("#folderCrumb")!.textContent = appState.selectedFolderName || "模型库";
|
||||
const params = new URLSearchParams({
|
||||
folderId: String(appState.selectedFolderId),
|
||||
page: String(appState.page),
|
||||
pageSize: String(appState.pageSize)
|
||||
});
|
||||
if (appState.filters.brandId) params.set("brandId", appState.filters.brandId);
|
||||
if (appState.filters.typeId) params.set("typeId", appState.filters.typeId);
|
||||
if (appState.filters.keyword) params.set("keyword", appState.filters.keyword);
|
||||
const result = await api<ModelListResponse>(`/api/models?${params.toString()}`);
|
||||
if (result.items.length === 0 && appState.page > 1) {
|
||||
appState.page -= 1;
|
||||
return loadModels();
|
||||
}
|
||||
document.querySelector("#modelCount")!.textContent = `${result.total} 个模型`;
|
||||
renderPagination({
|
||||
container: document.querySelector<HTMLElement>("#modelPagination")!,
|
||||
total: result.total,
|
||||
page: appState.page,
|
||||
pageSize: appState.pageSize,
|
||||
onChange: async (page, pageSize) => {
|
||||
appState.page = page;
|
||||
appState.pageSize = pageSize;
|
||||
await loadModels();
|
||||
}
|
||||
});
|
||||
grid.innerHTML = result.items.map(renderModelCard).join("") || `<div class="empty-state">当前目录暂无模型</div>`;
|
||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='preview']").forEach((button) => {
|
||||
const model = result.items.find((item) => item.id === Number(button.dataset.id));
|
||||
if (model) {
|
||||
button.addEventListener("click", async () => {
|
||||
const { openModelPreview } = await import("./preview");
|
||||
openModelPreview(model, loadModels);
|
||||
});
|
||||
}
|
||||
});
|
||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='edit']").forEach((button) => {
|
||||
button.addEventListener("click", () => editModel(Number(button.dataset.id)));
|
||||
});
|
||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='import']").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
notify("导入功能预留,后续接入当前模型的导入逻辑");
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll<HTMLButtonElement>("[data-action='delete']").forEach((button) => {
|
||||
button.addEventListener("click", () => deleteModel(Number(button.dataset.id)));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDictionaries() {
|
||||
const result = await api<DictionaryResponse>("/api/dictionaries");
|
||||
appState.brands = result.brands;
|
||||
appState.types = result.types;
|
||||
syncDictionarySelect("#brandFilter", result.brands, "全部品牌", appState.filters.brandId);
|
||||
syncDictionarySelect("#typeFilter", result.types, "全部类型", appState.filters.typeId);
|
||||
}
|
||||
|
||||
function syncDictionarySelect(selector: string, items: { id: number; name: string }[], emptyText: string, value: string) {
|
||||
const select = document.querySelector<HTMLSelectElement>(selector);
|
||||
if (!select) return;
|
||||
const current = select.value || value;
|
||||
select.innerHTML = `<option value="">${emptyText}</option>` + items
|
||||
.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`)
|
||||
.join("");
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
function renderModelCard(item: ModelItem) {
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
const prop = item.properties ?? {};
|
||||
const thumb = item.thumbnail_url
|
||||
? `<img src="${item.thumbnail_url}" alt="" />`
|
||||
: `<div class="thumb-placeholder">GLB</div>`;
|
||||
return `
|
||||
<article class="model-card" data-model-id="${item.id}">
|
||||
<div class="model-info">
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
</div>
|
||||
<div class="thumb">
|
||||
${thumb}
|
||||
<div class="thumb-actions">
|
||||
<button data-action="preview" data-id="${item.id}">预览</button>
|
||||
${isAdmin ? `<button data-action="edit" data-id="${item.id}">编辑</button>` : ""}
|
||||
<button data-action="import" data-id="${item.id}">导入</button>
|
||||
${isAdmin ? `<button data-action="delete" data-id="${item.id}">删除</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>品牌</dt><dd>${escapeHtml(item.brand_name ?? "")}</dd></div>
|
||||
<div><dt>类型</dt><dd>${escapeHtml(item.type_name ?? "")}</dd></div>
|
||||
<div><dt>型号</dt><dd>${escapeHtml(prop.model ?? "")}</dd></div>
|
||||
<div><dt>价钱</dt><dd>${escapeHtml(prop.price ?? "")}</dd></div>
|
||||
<div><dt>重量</dt><dd>${escapeHtml(prop.weight ?? "")}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async function openUploadModelDialog() {
|
||||
if (!appState.selectedFolderId) {
|
||||
notify("请先选择目录");
|
||||
return;
|
||||
}
|
||||
|
||||
const state: UploadFormState = { file: null };
|
||||
await loadDictionaries();
|
||||
const result = await formDialog<boolean>({
|
||||
title: "增加模型",
|
||||
width: 560,
|
||||
height: 520,
|
||||
body: `
|
||||
<form id="modelUploadPopupForm" class="popup-form upload-popup-form">
|
||||
<label>
|
||||
<span>模型文件</span>
|
||||
<div id="modelDropZone" class="drop-zone">
|
||||
<input id="modelUploadFile" type="file" accept=".glb" />
|
||||
<strong>选择模型</strong>
|
||||
<em>或拖拽 .glb 模型到这里</em>
|
||||
<small id="selectedFileName">未选择文件</small>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型名称</span>
|
||||
<input id="modelUploadName" name="name" placeholder="选择文件后自动填入" />
|
||||
</label>
|
||||
<div class="popup-form-grid">
|
||||
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
|
||||
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
|
||||
<label><span>型号</span><input name="model" /></label>
|
||||
<label><span>价钱</span><input name="price" /></label>
|
||||
<label><span>重量</span><input name="weight" /></label>
|
||||
</div>
|
||||
${dictionaryDatalistHtml()}
|
||||
</form>
|
||||
`,
|
||||
onOpen: () => bindUploadDialogEvents(state),
|
||||
onSubmit: async () => {
|
||||
if (!state.file) {
|
||||
throw new Error("请选择 .glb 模型文件");
|
||||
}
|
||||
const name = document.querySelector<HTMLInputElement>("#modelUploadName")?.value.trim();
|
||||
if (!name) {
|
||||
throw new Error("模型名称不能为空");
|
||||
}
|
||||
const form = new FormData(document.querySelector<HTMLFormElement>("#modelUploadPopupForm")!);
|
||||
const payload = new FormData();
|
||||
payload.set("folderId", String(appState.selectedFolderId));
|
||||
payload.set("name", name);
|
||||
payload.set("brandName", String(form.get("brandName") ?? ""));
|
||||
payload.set("typeName", String(form.get("typeName") ?? ""));
|
||||
payload.set("file", state.file);
|
||||
for (const key of ["model", "price", "weight"]) {
|
||||
payload.set(`prop.${key}`, String(form.get(key) ?? ""));
|
||||
}
|
||||
await api("/api/models/upload", {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
function bindUploadDialogEvents(state: UploadFormState) {
|
||||
const dropZone = document.querySelector<HTMLDivElement>("#modelDropZone")!;
|
||||
const fileInput = document.querySelector<HTMLInputElement>("#modelUploadFile")!;
|
||||
const nameInput = document.querySelector<HTMLInputElement>("#modelUploadName")!;
|
||||
const selectedFileName = document.querySelector<HTMLElement>("#selectedFileName")!;
|
||||
|
||||
const selectFile = (file: File) => {
|
||||
if (!file.name.toLowerCase().endsWith(".glb")) {
|
||||
notify("当前阶段只允许上传 .glb 模型");
|
||||
return;
|
||||
}
|
||||
state.file = file;
|
||||
selectedFileName.textContent = file.name;
|
||||
if (!nameInput.value.trim()) {
|
||||
nameInput.value = modelNameFromFile(file.name);
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) selectFile(file);
|
||||
});
|
||||
|
||||
dropZone.addEventListener("click", (event) => {
|
||||
if (event.target !== fileInput) fileInput.click();
|
||||
});
|
||||
|
||||
dropZone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragover");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", () => {
|
||||
dropZone.classList.remove("is-dragover");
|
||||
});
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragover");
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) selectFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function editModel(id: number) {
|
||||
const card = document.querySelector<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
|
||||
const oldName = card?.querySelector("strong")?.textContent ?? "";
|
||||
await loadDictionaries();
|
||||
const result = await formDialog<{ name: string; brandName: string; typeName: string; model: string; price: string; weight: string }>({
|
||||
title: "编辑模型",
|
||||
width: 520,
|
||||
height: 390,
|
||||
body: `
|
||||
<form id="modelEditPopupForm" class="popup-form">
|
||||
<label><span>模型名称</span><input name="name" value="${escapeHtml(oldName)}" /></label>
|
||||
<div class="popup-form-grid">
|
||||
<label><span>品牌</span><input name="brandName" list="brandOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(1) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>类型</span><input name="typeName" list="typeOptions" value="${escapeHtml(card?.querySelector("dl div:nth-child(2) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>型号</span><input name="model" value="${escapeHtml(card?.querySelector("dl div:nth-child(3) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>价钱</span><input name="price" value="${escapeHtml(card?.querySelector("dl div:nth-child(4) dd")?.textContent ?? "")}" /></label>
|
||||
<label><span>重量</span><input name="weight" value="${escapeHtml(card?.querySelector("dl div:nth-child(5) dd")?.textContent ?? "")}" /></label>
|
||||
</div>
|
||||
${dictionaryDatalistHtml()}
|
||||
</form>
|
||||
`,
|
||||
onSubmit: () => {
|
||||
const form = new FormData(document.querySelector<HTMLFormElement>("#modelEditPopupForm")!);
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
if (!name) {
|
||||
throw new Error("模型名称不能为空");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
brandName: String(form.get("brandName") ?? ""),
|
||||
typeName: String(form.get("typeName") ?? ""),
|
||||
model: String(form.get("model") ?? ""),
|
||||
price: String(form.get("price") ?? ""),
|
||||
weight: String(form.get("weight") ?? "")
|
||||
};
|
||||
}
|
||||
});
|
||||
if (!result) return;
|
||||
await api(`/api/models/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: result.name,
|
||||
brandName: result.brandName,
|
||||
typeName: result.typeName,
|
||||
properties: {
|
||||
model: result.model,
|
||||
price: result.price,
|
||||
weight: result.weight
|
||||
}
|
||||
})
|
||||
});
|
||||
await loadModels();
|
||||
}
|
||||
|
||||
function dictionaryDatalistHtml() {
|
||||
return `
|
||||
<datalist id="brandOptions">
|
||||
${appState.brands.map((item) => `<option value="${escapeHtml(item.name)}"></option>`).join("")}
|
||||
</datalist>
|
||||
<datalist id="typeOptions">
|
||||
${appState.types.map((item) => `<option value="${escapeHtml(item.name)}"></option>`).join("")}
|
||||
</datalist>
|
||||
`;
|
||||
}
|
||||
|
||||
async function deleteModel(id: number) {
|
||||
const confirmed = await confirmDialog("确认删除该模型?");
|
||||
if (!confirmed) return;
|
||||
await api(`/api/models/${id}`, { method: "DELETE" });
|
||||
await loadModels();
|
||||
}
|
||||
248
web/src/pages/app/modules/preview.ts
Normal file
248
web/src/pages/app/modules/preview.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { w2popup } from "w2ui";
|
||||
import type { ModelItem } from "../../../types";
|
||||
import { escapeHtml, formatBytes } from "../../../utils/format";
|
||||
import { notify, notifyError } from "../../../ui/dialogs";
|
||||
import { api } from "../../../services/api";
|
||||
import { getCurrentUser } from "../../../services/authState";
|
||||
|
||||
type PreviewRuntime = {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
controls: OrbitControls;
|
||||
animationId: number;
|
||||
resizeObserver: ResizeObserver;
|
||||
};
|
||||
|
||||
let runtime: PreviewRuntime | null = null;
|
||||
|
||||
export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Promise<void> | void) {
|
||||
disposePreview();
|
||||
const isAdmin = getCurrentUser()?.role === "admin";
|
||||
const url = model.file_url;
|
||||
const popup = w2popup.open({
|
||||
title: `模型预览 - ${escapeHtml(model.name)}`,
|
||||
width: 860,
|
||||
height: 620,
|
||||
modal: true,
|
||||
body: `
|
||||
<div class="preview-shell">
|
||||
<aside class="preview-process-panel">
|
||||
<div class="preview-process-section">
|
||||
<div class="preview-process-header">
|
||||
<strong>工艺播放</strong>
|
||||
<span>占位</span>
|
||||
</div>
|
||||
<div class="preview-model-meta">
|
||||
<span>模型大小</span>
|
||||
<strong>${formatBytes(model.file_size)}</strong>
|
||||
</div>
|
||||
<ul class="preview-process-tree">
|
||||
<li class="is-active">
|
||||
<button type="button" data-process-id="process-1">工艺 1</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" data-process-id="process-2">工艺 2</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" data-process-id="process-3">工艺 3</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="preview-process-actions">
|
||||
<button id="previewPlayBtn" type="button">播放</button>
|
||||
<button id="previewPauseBtn" type="button">暂停</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-scene-section">
|
||||
<div class="preview-process-header">
|
||||
<strong>导入场景</strong>
|
||||
<span>占位</span>
|
||||
</div>
|
||||
<div class="preview-scene-body">
|
||||
${isAdmin ? `
|
||||
<button id="previewCaptureThumbBtn" type="button">截缩略图</button>
|
||||
<label class="preview-switch">
|
||||
<input id="previewTransparentThumb" type="checkbox" />
|
||||
<span>透明背景截图</span>
|
||||
</label>
|
||||
` : ""}
|
||||
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
|
||||
<label class="preview-switch">
|
||||
<input id="previewUseBasePoint" type="checkbox" />
|
||||
<span>启用基点导入</span>
|
||||
</label>
|
||||
<div class="preview-basepoint-grid">
|
||||
<label><span>X</span><input name="baseX" type="number" value="0" step="0.001" /></label>
|
||||
<label><span>Y</span><input name="baseY" type="number" value="0" step="0.001" /></label>
|
||||
<label><span>Z</span><input name="baseZ" type="number" value="0" step="0.001" /></label>
|
||||
<label><span>RX</span><input name="baseRx" type="number" value="0" step="0.001" /></label>
|
||||
<label><span>RY</span><input name="baseRy" type="number" value="0" step="0.001" /></label>
|
||||
<label><span>RZ</span><input name="baseRz" type="number" value="0" step="0.001" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="preview-canvas-panel">
|
||||
<div id="modelPreviewViewport" class="preview-viewport">
|
||||
<div class="preview-loading">模型加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
actions: {
|
||||
关闭() {
|
||||
disposePreview();
|
||||
w2popup.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
popup.self
|
||||
.on("open:after", () => {
|
||||
bindProcessPlaceholder();
|
||||
if (isAdmin) bindThumbnailCapture(model.id, onThumbnailSaved);
|
||||
initPreview(url).catch((error) => notifyError(error));
|
||||
})
|
||||
.on("close:after", () => disposePreview());
|
||||
}
|
||||
|
||||
function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
|
||||
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
if (!runtime) {
|
||||
throw new Error("模型还未加载完成");
|
||||
}
|
||||
const transparent = document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
|
||||
runtime.controls.update();
|
||||
const oldBackground = runtime.scene.background;
|
||||
const oldClearAlpha = runtime.renderer.getClearAlpha();
|
||||
if (transparent) {
|
||||
runtime.scene.background = null;
|
||||
runtime.renderer.setClearColor(0x000000, 0);
|
||||
}
|
||||
runtime.renderer.render(runtime.scene, runtime.camera);
|
||||
const thumbnail = runtime.renderer.domElement.toDataURL("image/png");
|
||||
if (transparent) {
|
||||
runtime.scene.background = oldBackground;
|
||||
runtime.renderer.setClearAlpha(oldClearAlpha);
|
||||
runtime.renderer.render(runtime.scene, runtime.camera);
|
||||
}
|
||||
await api(`/api/models/${modelId}/thumbnail`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ thumbnail })
|
||||
});
|
||||
await onThumbnailSaved?.();
|
||||
notify("缩略图已保存");
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindProcessPlaceholder() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".preview-process-tree button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
|
||||
button.closest("li")?.classList.add("is-active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initPreview(url: string) {
|
||||
const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
|
||||
if (!viewport) return;
|
||||
viewport.innerHTML = "";
|
||||
|
||||
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xf4f7f9);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.position.set(3, -4, 2.5);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
viewport.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xb7c3cc, 1.2));
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 2);
|
||||
keyLight.position.set(4, -5, 6);
|
||||
scene.add(keyLight);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
const gltf = await loader.loadAsync(url);
|
||||
const object = gltf.scene;
|
||||
scene.add(object);
|
||||
fitCameraToObject(camera, controls, object);
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(viewport.clientWidth, 1);
|
||||
const height = Math.max(viewport.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(viewport);
|
||||
resize();
|
||||
|
||||
const animate = () => {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
if (runtime) {
|
||||
runtime.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
runtime = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
controls,
|
||||
animationId: requestAnimationFrame(animate),
|
||||
resizeObserver
|
||||
};
|
||||
}
|
||||
|
||||
function fitCameraToObject(camera: THREE.PerspectiveCamera, controls: OrbitControls, object: THREE.Object3D) {
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||||
const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));
|
||||
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
|
||||
camera.near = Math.max(distance / 100, 0.01);
|
||||
camera.far = distance * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
controls.target.copy(center);
|
||||
controls.minDistance = distance / 8;
|
||||
controls.maxDistance = distance * 8;
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function disposePreview() {
|
||||
if (!runtime) return;
|
||||
cancelAnimationFrame(runtime.animationId);
|
||||
runtime.resizeObserver.disconnect();
|
||||
runtime.controls.dispose();
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
runtime = null;
|
||||
}
|
||||
266
web/src/pages/app/modules/users.ts
Normal file
266
web/src/pages/app/modules/users.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { api } from "../../../services/api";
|
||||
import { getCurrentUser } from "../../../services/authState";
|
||||
import type { ManagedUser, UserListResponse, UserRole } from "../../../types";
|
||||
import { confirmDialog, formDialog, notify, notifyError } from "../../../ui/dialogs";
|
||||
import { escapeHtml } from "../../../utils/format";
|
||||
|
||||
type UserFormMode = "create" | "edit";
|
||||
|
||||
let users: ManagedUser[] = [];
|
||||
let editingUserId: number | null = null;
|
||||
|
||||
export async function openUserManager() {
|
||||
await refreshUsers();
|
||||
editingUserId = null;
|
||||
await formDialog<boolean>({
|
||||
title: "人员权限管理",
|
||||
width: 980,
|
||||
height: 560,
|
||||
body: `
|
||||
<div class="user-manager">
|
||||
<header class="user-manager-head">
|
||||
<div>
|
||||
<strong>账号列表</strong>
|
||||
<span>维护人员角色、启停状态和授权到期时间</span>
|
||||
</div>
|
||||
<button id="addUserBtn" class="primary-btn" type="button">新增人员</button>
|
||||
</header>
|
||||
<div class="user-manager-body">
|
||||
<div class="user-table">
|
||||
<div class="user-table-head">
|
||||
<span>用户</span>
|
||||
<span>角色</span>
|
||||
<span>状态</span>
|
||||
<span>授权到期</span>
|
||||
<span>更新时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div id="userTableBody" class="user-table-body">
|
||||
${userRowsHtml()}
|
||||
</div>
|
||||
</div>
|
||||
<aside id="userEditorHost" class="user-editor-panel">
|
||||
${userEditorEmptyHtml()}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
onOpen: bindUserManagerEvents,
|
||||
onSubmit: () => true
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
const result = await api<UserListResponse>("/api/users");
|
||||
users = result.users;
|
||||
}
|
||||
|
||||
function userRowsHtml() {
|
||||
return users.map(userRowHtml).join("") || `<div class="user-empty">暂无人员</div>`;
|
||||
}
|
||||
|
||||
function userRowHtml(user: ManagedUser) {
|
||||
const currentUser = getCurrentUser();
|
||||
const locked = currentUser?.id === user.id;
|
||||
const expired = isExpired(user.expires_at);
|
||||
const statusClass = user.enabled ? (expired ? "is-expired" : "is-enabled") : "is-disabled";
|
||||
const statusText = user.enabled ? (expired ? "已过期" : "启用") : "禁用";
|
||||
return `
|
||||
<div class="user-row" data-id="${user.id}">
|
||||
<span class="user-name">
|
||||
<strong>${escapeHtml(user.username)}</strong>
|
||||
${locked ? `<em>当前账号</em>` : ""}
|
||||
</span>
|
||||
<span><i class="role-badge ${user.role === "admin" ? "is-admin" : ""}">${roleText(user.role)}</i></span>
|
||||
<span><i class="status-badge ${statusClass}">${statusText}</i></span>
|
||||
<span>${formatDate(user.expires_at) || "长期有效"}</span>
|
||||
<span>${formatDate(user.updated_at)}</span>
|
||||
<span class="user-row-actions">
|
||||
<button type="button" data-action="edit" data-id="${user.id}">编辑</button>
|
||||
<button class="danger-text-btn" type="button" data-action="delete" data-id="${user.id}" ${locked ? "disabled" : ""}>删除</button>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindUserManagerEvents() {
|
||||
document.querySelector("#addUserBtn")?.addEventListener("click", () => {
|
||||
renderUserEditor("create");
|
||||
});
|
||||
bindUserRowEvents();
|
||||
bindUserEditorEvents();
|
||||
}
|
||||
|
||||
function bindUserRowEvents() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".user-table-body button").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const id = Number(button.dataset.id);
|
||||
if (button.dataset.action === "edit") renderUserEditor("edit", id);
|
||||
if (button.dataset.action === "delete") await deleteUser(id);
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderUserEditor(mode: UserFormMode, id?: number) {
|
||||
editingUserId = mode === "edit" ? id ?? null : null;
|
||||
const user = id ? users.find((item) => item.id === id) : undefined;
|
||||
const isCreate = mode === "create";
|
||||
const host = document.querySelector<HTMLElement>("#userEditorHost");
|
||||
if (!host) return;
|
||||
host.innerHTML = `
|
||||
<div class="user-editor-title">
|
||||
<strong>${isCreate ? "新增人员" : "编辑权限"}</strong>
|
||||
<span>${isCreate ? "创建账号并设置初始权限" : "调整角色、状态和授权期限"}</span>
|
||||
</div>
|
||||
<form id="userEditorForm" class="popup-form user-editor-form" data-mode="${mode}">
|
||||
${isCreate ? `
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input name="username" autocomplete="off" />
|
||||
</label>
|
||||
` : `
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input value="${escapeHtml(user?.username ?? "")}" disabled />
|
||||
</label>
|
||||
`}
|
||||
<div class="popup-form-grid">
|
||||
<label>
|
||||
<span>角色</span>
|
||||
<select name="role">
|
||||
<option value="user" ${user?.role === "user" ? "selected" : ""}>普通用户</option>
|
||||
<option value="admin" ${user?.role === "admin" ? "selected" : ""}>管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select name="enabled">
|
||||
<option value="true" ${user?.enabled === false ? "" : "selected"}>启用</option>
|
||||
<option value="false" ${user?.enabled === false ? "selected" : ""}>禁用</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>授权到期</span>
|
||||
<input name="expiresAt" type="date" value="${dateInputValue(user?.expires_at)}" />
|
||||
</label>
|
||||
<label>
|
||||
<span>${isCreate ? "初始密码" : "重置密码"}</span>
|
||||
<input name="password" type="password" autocomplete="new-password" placeholder="${isCreate ? "至少 6 位" : "不填写则保持原密码"}" />
|
||||
</label>
|
||||
<div class="user-editor-actions">
|
||||
<button id="cancelUserEditBtn" class="ghost-btn" type="button">取消</button>
|
||||
<button id="saveUserBtn" class="primary-btn" type="button">${isCreate ? "新增" : "保存"}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
bindUserEditorEvents();
|
||||
}
|
||||
|
||||
function userEditorEmptyHtml() {
|
||||
return `
|
||||
<div class="user-editor-empty">
|
||||
<strong>选择人员</strong>
|
||||
<span>点击新增或编辑后,在这里维护账号权限。</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindUserEditorEvents() {
|
||||
document.querySelector("#saveUserBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveUserEditor();
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
document.querySelector("#cancelUserEditBtn")?.addEventListener("click", () => {
|
||||
editingUserId = null;
|
||||
const host = document.querySelector<HTMLElement>("#userEditorHost");
|
||||
if (host) host.innerHTML = userEditorEmptyHtml();
|
||||
});
|
||||
}
|
||||
|
||||
async function saveUserEditor() {
|
||||
const formElement = document.querySelector<HTMLFormElement>("#userEditorForm");
|
||||
if (!formElement) return;
|
||||
const form = new FormData(formElement);
|
||||
const isCreate = formElement.dataset.mode === "create";
|
||||
const password = String(form.get("password") ?? "").trim();
|
||||
const result = {
|
||||
username: String(form.get("username") ?? "").trim(),
|
||||
password,
|
||||
role: String(form.get("role") ?? "user") as UserRole,
|
||||
enabled: String(form.get("enabled") ?? "true") === "true",
|
||||
expiresAt: String(form.get("expiresAt") ?? "") || null
|
||||
};
|
||||
if (isCreate && !result.username) throw new Error("用户名不能为空");
|
||||
if (isCreate && result.password.length < 6) throw new Error("初始密码至少 6 位");
|
||||
if (!isCreate && result.password && result.password.length < 6) throw new Error("重置密码至少 6 位");
|
||||
if (isCreate) {
|
||||
await api("/api/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(result)
|
||||
});
|
||||
notify("新增成功");
|
||||
} else {
|
||||
if (!editingUserId) throw new Error("请选择要编辑的用户");
|
||||
await api(`/api/users/${editingUserId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
role: result.role,
|
||||
enabled: result.enabled,
|
||||
expiresAt: result.expiresAt,
|
||||
...(result.password ? { password: result.password } : {})
|
||||
})
|
||||
});
|
||||
notify("保存成功");
|
||||
}
|
||||
await rerenderUserRows();
|
||||
if (isCreate) {
|
||||
const host = document.querySelector<HTMLElement>("#userEditorHost");
|
||||
if (host) host.innerHTML = userEditorEmptyHtml();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
const user = users.find((item) => item.id === id);
|
||||
if (!user) return;
|
||||
const confirmed = await confirmDialog(`确认删除用户「${user.username}」?`);
|
||||
if (!confirmed) return;
|
||||
await api(`/api/users/${id}`, { method: "DELETE" });
|
||||
notify("删除成功");
|
||||
await rerenderUserRows();
|
||||
}
|
||||
|
||||
async function rerenderUserRows() {
|
||||
await refreshUsers();
|
||||
const body = document.querySelector<HTMLDivElement>("#userTableBody");
|
||||
if (!body) return;
|
||||
body.innerHTML = userRowsHtml();
|
||||
bindUserRowEvents();
|
||||
if (editingUserId) {
|
||||
renderUserEditor("edit", editingUserId);
|
||||
}
|
||||
}
|
||||
|
||||
function roleText(role: UserRole) {
|
||||
return role === "admin" ? "管理员" : "普通用户";
|
||||
}
|
||||
|
||||
function isExpired(value: string | null) {
|
||||
return Boolean(value && new Date(value).getTime() < Date.now());
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return "";
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function dateInputValue(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
52
web/src/pages/login/login.css
Normal file
52
web/src/pages/login/login.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #dce8ef, #f4f6f0);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(360px, 100%);
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9e0e6;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 28px rgba(40, 58, 75, 0.12);
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-panel p {
|
||||
margin: 0 0 18px;
|
||||
color: #667789;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-stack label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 78px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.captcha-box {
|
||||
background: repeating-linear-gradient(45deg, #edf3f6, #edf3f6 8px, #dbe6ec 8px, #dbe6ec 16px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 20px;
|
||||
color: #b42318;
|
||||
}
|
||||
76
web/src/pages/login/login.ts
Normal file
76
web/src/pages/login/login.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { AuthUser } from "../../types";
|
||||
import { api } from "../../services/api";
|
||||
import { setSession } from "../../services/authState";
|
||||
import { notify, notifyError } from "../../ui/dialogs";
|
||||
import "./login.css";
|
||||
|
||||
export function renderLogin(onSuccess: () => Promise<void> | void) {
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<main class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div>
|
||||
<h1>DMT 模型库</h1>
|
||||
<p>模型文件、目录和属性维护</p>
|
||||
</div>
|
||||
<form id="loginForm" class="form-stack">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input name="username" autocomplete="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" autocomplete="current-password" value="admin123" />
|
||||
</label>
|
||||
<label>
|
||||
<span>图片验证码</span>
|
||||
<div class="captcha-row">
|
||||
<input name="captcha" value="1234" />
|
||||
<button type="button" class="captcha-box">1234</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="primary-btn" type="submit">登录</button>
|
||||
<button class="ghost-btn" id="registerBtn" type="button">注册普通账号</button>
|
||||
<p id="loginMessage" class="message"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
document.querySelector<HTMLFormElement>("#loginForm")!.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
try {
|
||||
const result = await api<{ token: string; user: AuthUser }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
captcha: form.get("captcha")
|
||||
})
|
||||
});
|
||||
setSession(result.token, result.user);
|
||||
await onSuccess();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
document.querySelector("#loginMessage")!.textContent = message;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector<HTMLButtonElement>("#registerBtn")!.addEventListener("click", async () => {
|
||||
const form = new FormData(document.querySelector<HTMLFormElement>("#loginForm")!);
|
||||
try {
|
||||
await api("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
captcha: form.get("captcha")
|
||||
})
|
||||
});
|
||||
notify("注册成功,可以登录");
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
23
web/src/services/api.ts
Normal file
23
web/src/services/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getToken } from "./authState";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||
...authHeaders(),
|
||||
...((options.headers as Record<string, string> | undefined) ?? {})
|
||||
} as HeadersInit
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message ?? "请求失败");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
29
web/src/services/authState.ts
Normal file
29
web/src/services/authState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
let token = localStorage.getItem("token") ?? "";
|
||||
let currentUser: AuthUser | null = null;
|
||||
|
||||
export function getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
export function setSession(nextToken: string, user: AuthUser) {
|
||||
token = nextToken;
|
||||
currentUser = user;
|
||||
localStorage.setItem("token", nextToken);
|
||||
}
|
||||
|
||||
export function setCurrentUser(user: AuthUser) {
|
||||
currentUser = user;
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
token = "";
|
||||
currentUser = null;
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
103
web/src/styles/base.css
Normal file
103
web/src/styles/base.css
Normal file
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1c2733;
|
||||
background: #eef2f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #c6d0da;
|
||||
background: #ffffff;
|
||||
color: #243447;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid #c9d3dd;
|
||||
border-radius: 4px;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: #1f6f8b;
|
||||
border-color: #1f6f8b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: #f7fafb;
|
||||
}
|
||||
|
||||
.w2ui-panel .w2ui-panel-content {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jstree-default .jstree-anchor {
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.jstree-default .jstree-icon:empty {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.vakata-context li > a {
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0 18px 0 8px;
|
||||
}
|
||||
|
||||
.vakata-context,
|
||||
.vakata-context ul {
|
||||
z-index: 12000 !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 1px 6px 0 2px;
|
||||
background-position: center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: 16px 16px !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon-add {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 6.5A2.5 2.5 0 0 1 5.5 4H10l2 2h6.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-10Z' stroke='%231f6f8b' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M12 9.5v6M9 12.5h6' stroke='%231f6f8b' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon-edit {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 20h4.5L19.2 9.3a2.1 2.1 0 0 0 0-3L17.7 4.8a2.1 2.1 0 0 0-3 0L4 15.5V20Z' stroke='%2374612f' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M13.5 6 18 10.5' stroke='%23c08a28' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
.vakata-context .tree-menu-icon-delete {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7h14' stroke='%23b42318' stroke-width='1.8' stroke-linecap='round'/%3E%3Cpath d='M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7' stroke='%23b42318' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M7 7l1 12a2 2 0 0 0 2 1.8h4a2 2 0 0 0 2-1.8l1-12' stroke='%23b42318' stroke-width='1.8' stroke-linejoin='round'/%3E%3Cpath d='M10.5 11v6M13.5 11v6' stroke='%23b42318' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
70
web/src/types.ts
Normal file
70
web/src/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
export type UserRole = "admin" | "user";
|
||||
|
||||
export type ManagedUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
enabled: boolean;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UserListResponse = {
|
||||
users: ManagedUser[];
|
||||
};
|
||||
|
||||
export type Folder = {
|
||||
id: number;
|
||||
parent_id: number | null;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ModelItem = {
|
||||
id: number;
|
||||
folder_id: number;
|
||||
brand_id: number | null;
|
||||
type_id: number | null;
|
||||
name: string;
|
||||
original_filename: string;
|
||||
file_path: string;
|
||||
file_url: string;
|
||||
storage_provider: "local" | "cos";
|
||||
file_size: number;
|
||||
thumbnail: string | null;
|
||||
thumbnail_path: string | null;
|
||||
thumbnail_provider: "local" | "cos" | null;
|
||||
thumbnail_url: string | null;
|
||||
brand_name: string | null;
|
||||
type_name: string | null;
|
||||
properties: Record<string, string>;
|
||||
};
|
||||
|
||||
export type FolderTreeResponse = {
|
||||
folders: Folder[];
|
||||
tree: unknown[];
|
||||
};
|
||||
|
||||
export type ModelListResponse = {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: ModelItem[];
|
||||
};
|
||||
|
||||
export type DictionaryItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DictionaryResponse = {
|
||||
brands: DictionaryItem[];
|
||||
types: DictionaryItem[];
|
||||
};
|
||||
26
web/src/types/three-examples.d.ts
vendored
Normal file
26
web/src/types/three-examples.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
declare module "three/examples/jsm/controls/OrbitControls.js" {
|
||||
import { Camera, EventDispatcher, Vector3 } from "three";
|
||||
|
||||
export class OrbitControls extends EventDispatcher {
|
||||
constructor(object: Camera, domElement?: HTMLElement);
|
||||
target: Vector3;
|
||||
enableDamping: boolean;
|
||||
minDistance: number;
|
||||
maxDistance: number;
|
||||
update(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "three/examples/jsm/loaders/GLTFLoader.js" {
|
||||
import { LoadingManager, Object3D } from "three";
|
||||
|
||||
export type GLTF = {
|
||||
scene: Object3D;
|
||||
};
|
||||
|
||||
export class GLTFLoader {
|
||||
constructor(manager?: LoadingManager);
|
||||
loadAsync(url: string): Promise<GLTF>;
|
||||
}
|
||||
}
|
||||
47
web/src/types/w2ui.d.ts
vendored
Normal file
47
web/src/types/w2ui.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
declare module "w2ui" {
|
||||
type W2EventHandler = (event: unknown) => void;
|
||||
type W2EventHost = {
|
||||
on(eventName: string, handler: W2EventHandler): W2EventHost;
|
||||
};
|
||||
|
||||
export const w2popup: {
|
||||
open(options: {
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modal?: boolean;
|
||||
body?: string;
|
||||
actions?: Record<string, () => void | Promise<void>>;
|
||||
}): {
|
||||
self: W2EventHost;
|
||||
};
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export class w2layout {
|
||||
constructor(options: {
|
||||
name: string;
|
||||
padding?: number;
|
||||
panels: Array<{
|
||||
type: "left" | "right" | "top" | "bottom" | "main" | "preview";
|
||||
size?: number | string;
|
||||
minSize?: number;
|
||||
maxSize?: number | false;
|
||||
resizable?: boolean;
|
||||
overflow?: string;
|
||||
html?: string;
|
||||
}>;
|
||||
});
|
||||
render(target: HTMLElement): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export const w2ui: Record<string, { destroy?: () => void } | undefined>;
|
||||
|
||||
export function w2alert(message: string, title?: string): unknown;
|
||||
export function w2confirm(
|
||||
message: string | { msg: string; title?: string; yes?: string; no?: string },
|
||||
title?: string,
|
||||
callback?: (action: string) => void
|
||||
): unknown;
|
||||
}
|
||||
112
web/src/ui/dialogs.ts
Normal file
112
web/src/ui/dialogs.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { w2alert, w2confirm, w2popup } from "w2ui";
|
||||
|
||||
type PopupActionEvent = {
|
||||
detail: {
|
||||
action: string;
|
||||
self?: {
|
||||
close?: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function notify(message: string, title = "提示") {
|
||||
w2alert(message, title);
|
||||
}
|
||||
|
||||
export function notifyError(error: unknown) {
|
||||
notify(error instanceof Error ? error.message : String(error), "错误");
|
||||
}
|
||||
|
||||
export function confirmDialog(message: string, title = "确认") {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
w2confirm({
|
||||
msg: message,
|
||||
title,
|
||||
yes: "确定",
|
||||
no: "取消"
|
||||
}, undefined, (action: string) => {
|
||||
resolve(action === "yes" || action === "Yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function formDialog<T>(options: {
|
||||
title: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
body: string;
|
||||
onOpen?: () => void;
|
||||
onSubmit: () => Promise<T> | T;
|
||||
}) {
|
||||
return new Promise<T | null>((resolve) => {
|
||||
let settled = false;
|
||||
const closeWith = (value: T | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
w2popup.close();
|
||||
};
|
||||
|
||||
const popup = w2popup.open({
|
||||
title: options.title,
|
||||
width: options.width ?? 460,
|
||||
height: options.height ?? 300,
|
||||
modal: true,
|
||||
body: options.body,
|
||||
actions: {
|
||||
取消() {
|
||||
closeWith(null);
|
||||
},
|
||||
async 确定() {
|
||||
try {
|
||||
const result = await options.onSubmit();
|
||||
closeWith(result);
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
popup.self
|
||||
.on("open:after", () => options.onOpen?.())
|
||||
.on("close:after", () => {
|
||||
if (!settled) resolve(null);
|
||||
})
|
||||
.on("action:after", (event: unknown) => {
|
||||
const popupEvent = event as PopupActionEvent;
|
||||
if (popupEvent.detail.action === "close") {
|
||||
closeWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function promptDialog(options: {
|
||||
title: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const id = `field-${Date.now()}`;
|
||||
return formDialog<string>({
|
||||
title: options.title,
|
||||
height: 210,
|
||||
body: `
|
||||
<div class="popup-form">
|
||||
<label>
|
||||
<span>${options.label}</span>
|
||||
<input id="${id}" value="${options.value ?? ""}" placeholder="${options.placeholder ?? ""}" />
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
onOpen: () => document.querySelector<HTMLInputElement>(`#${id}`)?.focus(),
|
||||
onSubmit: () => {
|
||||
const value = document.querySelector<HTMLInputElement>(`#${id}`)?.value.trim() ?? "";
|
||||
if (!value) {
|
||||
throw new Error(`${options.label}不能为空`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
20
web/src/utils/format.ts
Normal file
20
web/src/utils/format.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char]!));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function modelNameFromFile(fileName: string) {
|
||||
return fileName.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
|
||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal 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
12
web/vite.config.ts
Normal 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
18
设计文档.md
Normal 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. 用户管理界面(账号禁用启用,时间授权),权限管理(允许用户访问那些文件夹中的模型)界面
|
||||
Reference in New Issue
Block a user