diff --git a/.env.example b/.env.example index d597584..7e9e0ef 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,10 @@ DB_PATH= # login: show login page # fixed: auto login with FIXED_LOGIN_USERNAME and open model library directly -APP_AUTH_MODE=login +APP_AUTH_MODE=fixed FIXED_LOGIN_USERNAME=viewer FIXED_LOGIN_ROLE=user +ALLOW_USER_EDIT_SYSTEM_LIBRARY=false # local: write to server/storage and serve via /storage # cos: upload models to Tencent Cloud COS and read through CDN_BASE_URL diff --git a/server/src/config.ts b/server/src/config.ts index 2155857..3211808 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -13,26 +13,32 @@ function fixedLoginRole(): UserRole { return process.env.FIXED_LOGIN_ROLE === "admin" ? "admin" : "user"; } +function envValue(name: string) { + const value = process.env[name]?.trim(); + return value || undefined; +} + 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", + host: envValue("HOST") ?? "0.0.0.0", + port: Number(envValue("PORT") ?? 3001), + jwtSecret: envValue("JWT_SECRET") ?? "dev-only-change-me", authMode: process.env.APP_AUTH_MODE === "fixed" ? "fixed" : "login", fixedLogin: { - username: process.env.FIXED_LOGIN_USERNAME ?? "viewer", + username: envValue("FIXED_LOGIN_USERNAME") ?? "viewer", role: fixedLoginRole() }, + allowUserEditSystemLibrary: envValue("ALLOW_USER_EDIT_SYSTEM_LIBRARY") === "true", 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 ?? "", + databasePath: envValue("DB_PATH") ?? path.resolve(serverRoot, "data", "model-library.db"), + storageDriver: (envValue("STORAGE_DRIVER") ?? "local").toLowerCase(), + publicBaseUrl: envValue("PUBLIC_BASE_URL") ?? "", + cdnBaseUrl: envValue("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, "") + secretId: envValue("COS_SECRET_ID") ?? "", + secretKey: envValue("COS_SECRET_KEY") ?? "", + bucket: envValue("COS_BUCKET") ?? "", + region: envValue("COS_REGION") ?? "", + prefix: (envValue("COS_PREFIX") ?? "").replace(/^\/+|\/+$/g, "") } }; diff --git a/server/src/db.ts b/server/src/db.ts index 158bf55..67d85c6 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -10,11 +10,6 @@ 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 ( @@ -33,6 +28,9 @@ export function migrate() { parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, + library_type TEXT NOT NULL DEFAULT 'system', + owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + is_system INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(parent_id, name) @@ -60,11 +58,13 @@ export function migrate() { name TEXT NOT NULL, original_filename TEXT NOT NULL, file_path TEXT NOT NULL UNIQUE, + library_type TEXT NOT NULL DEFAULT 'system', + owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, storage_provider TEXT NOT NULL DEFAULT 'local', file_size INTEGER NOT NULL, - thumbnail TEXT, thumbnail_path TEXT, thumbnail_provider TEXT, + operation_tree TEXT NOT NULL DEFAULT '{"OperationTree":"[]"}', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(folder_id, name) @@ -83,32 +83,13 @@ export function migrate() { 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); + CREATE INDEX IF NOT EXISTS idx_folders_library_owner ON folders(library_type, owner_user_id); + CREATE INDEX IF NOT EXISTS idx_models_library_owner ON models(library_type, owner_user_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(` @@ -116,4 +97,12 @@ export function migrate() { VALUES (?, ?, 'admin', 1) `).run("admin", bcrypt.hashSync("admin123", 10)); } + + const systemRoot = db.prepare("SELECT id FROM folders WHERE path = ?").get("system") as { id: number } | undefined; + if (!systemRoot) { + db.prepare(` + INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system) + VALUES (NULL, ?, ?, 'system', NULL, 1) + `).run("系统模型库", "system"); + } } diff --git a/server/src/folders.ts b/server/src/folders.ts index 464c5b8..a326488 100644 --- a/server/src/folders.ts +++ b/server/src/folders.ts @@ -3,7 +3,8 @@ 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 { requireAuth } from "./auth.js"; +import { folderPermissions, getFolder, requireFolderWrite } from "./permissions.js"; import { storage } from "./storage.js"; import { FolderRow } from "./types.js"; import { assertValidFolderName, ensureDir, removeIfExists, safeStoragePath, toFolderStorageRelative } from "./utils.js"; @@ -17,65 +18,125 @@ const updateSchema = z.object({ name: z.string() }); -function buildTree(rows: FolderRow[]) { - const nodes = rows.map((row) => ({ +function folderPayload(request: { userInfo: { id: number; username: string; role: "admin" | "user" } }, row: FolderRow) { + return { + ...row, + permissions: folderPermissions(request.userInfo, row) + }; +} + +function buildFolderNodes(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) { + return rows.map((row) => ({ id: String(row.id), - text: `${row.name}(${row.model_count ?? 0})`, + text: `${displayFolderName(row)}(${row.model_count ?? 0})`, parentId: row.parent_id, path: row.path, + data: { + permissions: folderPermissions(userInfo, row), + libraryType: row.library_type, + ownerUserId: row.owner_user_id, + isSystem: Boolean(row.is_system) + }, children: [] as unknown[] })); +} + +function buildTree(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) { + const nodes = buildFolderNodes(rows, userInfo); + const userGroupNode = { + id: "virtual:user-libraries", + text: "用户模型库", + parentId: null as number | null, + path: "users", + data: { + permissions: { read: true, write: false }, + virtual: true + }, + 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 if (node.data.libraryType === "user") { + userGroupNode.children.push(node); } else { roots.push(node); } } + if (userGroupNode.children.length > 0) { + roots.push(userGroupNode); + } 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("根目录不能重命名"); + if (folder.is_system) { + throw new Error("系统保留目录不能重命名"); } } +function displayFolderName(row: FolderRow) { + if (row.library_type === "user" && row.is_system && row.owner_username) { + return row.owner_username; + } + return row.name; +} + +function ensureUserLibrary(user: { id: number; username: string }) { + const pathValue = `users/${user.id}`; + const existing = db.prepare("SELECT id FROM folders WHERE path = ?").get(pathValue) as { id: number } | undefined; + if (existing) return existing.id; + const result = db.prepare(` + INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system) + VALUES (NULL, ?, ?, 'user', ?, 1) + `).run("用户模型库", pathValue, user.id); + return Number(result.lastInsertRowid); +} + export async function folderRoutes(app: FastifyInstance) { - app.get("/api/folders", { preHandler: [requireAuth] }, async () => { + app.get("/api/folders", { preHandler: [requireAuth] }, async (request) => { + ensureUserLibrary(request.userInfo); const rows = db.prepare(` SELECT f.*, + u.username AS owner_username, COUNT(m.id) AS model_count FROM folders f + LEFT JOIN users u ON u.id = f.owner_user_id LEFT JOIN models m ON m.folder_id = f.id GROUP BY f.id - ORDER BY f.parent_id, f.name + ORDER BY + CASE f.library_type WHEN 'system' THEN 0 ELSE 1 END, + f.owner_user_id, + f.parent_id, + f.name `).all() as unknown as FolderRow[]; - return { folders: rows, tree: buildTree(rows) }; + return { folders: rows.map((row) => folderPayload(request, row)), tree: buildTree(rows, request.userInfo) }; }); - app.post("/api/folders", { preHandler: [requireAdmin] }, async (request, reply) => { + app.post("/api/folders", { preHandler: [requireAuth] }, 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: "父目录不存在" }); } + if (!parent) { + return reply.code(400).send({ message: "请选择父目录" }); + } + if (!requireFolderWrite(request, reply, parent)) return; const folderPath = childPath(parent, name); try { - const result = db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (?, ?, ?)") - .run(body.parentId ?? null, name, folderPath); + const result = db.prepare(` + INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system) + VALUES (?, ?, ?, ?, ?, 0) + `).run(parent.id, name, folderPath, parent.library_type, parent.owner_user_id); await ensureDir(safeStoragePath(toFolderStorageRelative(folderPath))); return reply.code(201).send({ id: result.lastInsertRowid, name, path: folderPath }); } catch { @@ -83,7 +144,7 @@ export async function folderRoutes(app: FastifyInstance) { } }); - app.put("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => { + app.put("/api/folders/:id", { preHandler: [requireAuth] }, 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); @@ -91,6 +152,7 @@ export async function folderRoutes(app: FastifyInstance) { if (!folder) { return reply.code(404).send({ message: "目录不存在" }); } + if (!requireFolderWrite(request, reply, folder)) return; assertCanRenameRoot(folder); const parent = folder.parent_id ? getFolder(folder.parent_id) : undefined; const oldPath = folder.path; @@ -146,14 +208,15 @@ export async function folderRoutes(app: FastifyInstance) { } }); - app.delete("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => { + app.delete("/api/folders/:id", { preHandler: [requireAuth] }, 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: "根目录不能删除" }); + if (!requireFolderWrite(request, reply, folder)) return; + if (folder.is_system) { + return reply.code(400).send({ message: "系统保留目录不能删除" }); } db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id); await removeIfExists(safeStoragePath(toFolderStorageRelative(folder.path))); diff --git a/server/src/models.ts b/server/src/models.ts index 8152992..8a2caf6 100644 --- a/server/src/models.ts +++ b/server/src/models.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { nanoid } from "nanoid"; import { db } from "./db.js"; import { requireAdmin, requireAuth } from "./auth.js"; +import { getFolder, modelPermissions, requireFolderWrite, requireModelWrite } from "./permissions.js"; import { deleteStoredObject, publicObjectUrl, storage } from "./storage.js"; import { DictionaryRow, FolderRow, ModelRow } from "./types.js"; import { @@ -16,7 +17,7 @@ const updateSchema = z.object({ name: z.string().min(1), brandName: z.string().optional(), typeName: z.string().optional(), - thumbnail: z.string().nullable().optional(), + operationTree: z.string().optional(), properties: z.record(z.string()).optional() }); @@ -32,9 +33,7 @@ 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; -} +const defaultOperationTree = JSON.stringify({ OperationTree: "[]" }); function getProperties(modelId: number) { const rows = db.prepare("SELECT property_key, property_value FROM model_properties WHERE model_id = ?") @@ -56,13 +55,14 @@ function saveProperties(modelId: number, properties: Record) { } } -function modelPayload(row: ModelRow) { +function modelPayload(row: ModelRow, userInfo?: { id: number; username: string; role: "admin" | "user" }) { 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, + : null, + permissions: userInfo ? modelPermissions(userInfo, row) : { read: true, write: false }, properties: getProperties(row.id) }; } @@ -78,6 +78,26 @@ function dataImageToBuffer(dataUrl: string) { }; } +function normalizeOperationTree(value: string | undefined) { + const clean = value?.trim(); + if (!clean) return defaultOperationTree; + let parsed: unknown; + try { + parsed = JSON.parse(clean); + } catch { + throw new Error("工艺数据必须是合法 JSON 字符串"); + } + if (!parsed || typeof parsed !== "object" || typeof (parsed as { OperationTree?: unknown }).OperationTree !== "string") { + throw new Error("工艺数据格式必须符合 OperationTreeDB"); + } + try { + JSON.parse((parsed as { OperationTree: string }).OperationTree); + } catch { + throw new Error("OperationTree 字段必须是合法 JSON 字符串"); + } + return JSON.stringify(parsed); +} + function normalizeDictionaryName(name: string | undefined) { const clean = name?.trim(); return clean || null; @@ -205,19 +225,19 @@ export async function modelRoutes(app: FastifyInstance) { total: total.count, page: query.page, pageSize: query.pageSize, - items: rows.map(modelPayload) + items: rows.map((row) => modelPayload(row, request.userInfo)) }; }); - app.post("/api/models/upload", { preHandler: [requireAdmin] }, async (request, reply) => { + app.post("/api/models/upload", { preHandler: [requireAuth] }, async (request, reply) => { const parts = request.parts(); let folderId: number | undefined; - let thumbnail: string | null = null; const properties: Record = {}; let uploaded: { filename: string; buffer: Buffer } | undefined; let modelName: string | undefined; let brandName: string | undefined; let typeName: string | undefined; + let operationTree: string | undefined; for await (const part of parts) { if (part.type === "file") { @@ -234,7 +254,7 @@ export async function modelRoutes(app: FastifyInstance) { 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 === "operationTree") operationTree = String(part.value ?? ""); if (part.fieldname.startsWith("prop.")) { properties[part.fieldname.slice(5)] = String(part.value ?? ""); } @@ -248,6 +268,7 @@ export async function modelRoutes(app: FastifyInstance) { if (!folder) { return reply.code(404).send({ message: "目录不存在" }); } + if (!requireFolderWrite(request, reply, folder)) return; const originalModelName = assertValidFolderName(modelName || normalizeModelName(uploaded.filename)); const storageFilename = `${normalizeModelName(uploaded.filename)}-${nanoid(8)}.glb`; @@ -262,10 +283,11 @@ export async function modelRoutes(app: FastifyInstance) { try { const brandId = findOrCreateDictionary("brands", brandName); const typeId = findOrCreateDictionary("model_types", typeName); + const normalizedOperationTree = normalizeOperationTree(operationTree); 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); + INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, library_type, owner_user_id, storage_provider, file_size, operation_tree) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, folder.library_type, folder.owner_user_id, stored.provider, uploaded.buffer.length, normalizedOperationTree); saveProperties(Number(result.lastInsertRowid), properties); return reply.code(201).send({ id: result.lastInsertRowid }); } catch (error) { @@ -274,22 +296,24 @@ export async function modelRoutes(app: FastifyInstance) { } }); - app.put("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => { + app.put("/api/models/:id", { preHandler: [requireAuth] }, 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: "模型不存在" }); } + if (!requireModelWrite(request, reply, model)) return; const name = assertValidFolderName(body.name); try { const brandId = findOrCreateDictionary("brands", body.brandName); const typeId = findOrCreateDictionary("model_types", body.typeName); + const operationTree = body.operationTree === undefined ? model.operation_tree : normalizeOperationTree(body.operationTree); db.prepare(` UPDATE models - SET name = ?, brand_id = ?, type_id = ?, thumbnail = ?, updated_at = CURRENT_TIMESTAMP + SET name = ?, brand_id = ?, type_id = ?, operation_tree = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(name, brandId, typeId, body.thumbnail ?? model.thumbnail, model.id); + `).run(name, brandId, typeId, operationTree, model.id); if (body.properties) { saveProperties(model.id, body.properties); } @@ -299,19 +323,20 @@ export async function modelRoutes(app: FastifyInstance) { } }); - app.put("/api/models/:id/thumbnail", { preHandler: [requireAdmin] }, async (request, reply) => { + app.put("/api/models/:id/thumbnail", { preHandler: [requireAuth] }, 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: "模型不存在" }); } + if (!requireModelWrite(request, reply, model)) return; 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 + SET thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(stored.key, stored.provider, model.id); if (model.thumbnail_path && model.thumbnail_provider) { @@ -324,12 +349,13 @@ export async function modelRoutes(app: FastifyInstance) { } }); - app.delete("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => { + app.delete("/api/models/:id", { preHandler: [requireAuth] }, 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: "模型不存在" }); } + if (!requireModelWrite(request, reply, model)) return; 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) { diff --git a/server/src/permissions.ts b/server/src/permissions.ts new file mode 100644 index 0000000..37e3938 --- /dev/null +++ b/server/src/permissions.ts @@ -0,0 +1,55 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { config } from "./config.js"; +import { db } from "./db.js"; +import { FolderRow, ModelRow } from "./types.js"; + +export type PermissionPayload = { + read: boolean; + write: boolean; +}; + +export function canWriteLibrary(user: FastifyRequest["userInfo"], libraryType: "system" | "user", ownerUserId: number | null) { + if (user.role === "admin") return true; + if (libraryType === "user" && ownerUserId === user.id) return true; + if (libraryType === "system" && config.allowUserEditSystemLibrary) return true; + return false; +} + +export function folderPermissions(user: FastifyRequest["userInfo"], folder: Pick): PermissionPayload { + return { + read: true, + write: canWriteLibrary(user, folder.library_type, folder.owner_user_id) + }; +} + +export function modelPermissions(user: FastifyRequest["userInfo"], model: Pick): PermissionPayload { + return { + read: true, + write: canWriteLibrary(user, model.library_type, model.owner_user_id) + }; +} + +export function getFolder(id: number) { + return db.prepare(` + SELECT f.*, u.username AS owner_username + FROM folders f + LEFT JOIN users u ON u.id = f.owner_user_id + WHERE f.id = ? + `).get(id) as FolderRow | undefined; +} + +export function requireFolderWrite(request: FastifyRequest, reply: FastifyReply, folder: FolderRow) { + if (!folderPermissions(request.userInfo, folder).write) { + reply.code(403).send({ message: "没有该模型库的操作权限" }); + return false; + } + return true; +} + +export function requireModelWrite(request: FastifyRequest, reply: FastifyReply, model: ModelRow) { + if (!modelPermissions(request.userInfo, model).write) { + reply.code(403).send({ message: "没有该模型的操作权限" }); + return false; + } + return true; +} diff --git a/server/src/types.ts b/server/src/types.ts index 08a687d..19d8065 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -22,6 +22,10 @@ export interface FolderRow { parent_id: number | null; name: string; path: string; + library_type: "system" | "user"; + owner_user_id: number | null; + is_system: number; + owner_username?: string | null; model_count?: number; created_at: string; updated_at: string; @@ -35,11 +39,13 @@ export interface ModelRow { name: string; original_filename: string; file_path: string; + library_type: "system" | "user"; + owner_user_id: number | null; storage_provider: "local" | "cos"; file_size: number; - thumbnail: string | null; thumbnail_path: string | null; thumbnail_provider: "local" | "cos" | null; + operation_tree: string; brand_name?: string | null; type_name?: string | null; created_at: string; diff --git a/web/src/pages/app/dialogs.css b/web/src/pages/app/dialogs.css index 53d6fc3..2cad060 100644 --- a/web/src/pages/app/dialogs.css +++ b/web/src/pages/app/dialogs.css @@ -134,6 +134,12 @@ margin: 0 0 4px; } +.preview-process-empty { + padding: 28px 8px; + color: #7a8997; + text-align: center; +} + .preview-process-tree button { width: 100%; text-align: left; @@ -155,6 +161,14 @@ border-top: 1px solid #d8e0e8; } +.preview-process-status { + padding: 6px 8px 8px; + border-top: 1px solid #eef2f5; + color: #647586; + font-size: 12px; + line-height: 1.4; +} + .preview-scene-section { border-top: 1px solid #d8e0e8; } diff --git a/web/src/pages/app/layout.ts b/web/src/pages/app/layout.ts index 2ea2cbd..29b3470 100644 --- a/web/src/pages/app/layout.ts +++ b/web/src/pages/app/layout.ts @@ -37,7 +37,7 @@ export async function renderApp() {
${isAdmin ? `` : ""} ${isAdmin ? `` : ""} - ${isAdmin ? `` : ""} +
diff --git a/web/src/pages/app/modules/folders.ts b/web/src/pages/app/modules/folders.ts index 15cf309..e11c87a 100644 --- a/web/src/pages/app/modules/folders.ts +++ b/web/src/pages/app/modules/folders.ts @@ -12,6 +12,10 @@ type JsTreeNode = { text: string; }; +function isRealFolderId(id: string) { + return /^\d+$/.test(id); +} + function selectFolder(node: JsTreeNode) { appState.selectedFolderId = Number(node.id); appState.selectedFolderName = appState.folders.find((folder) => folder.id === Number(node.id))?.name ?? node.text; @@ -60,7 +64,6 @@ async function runFolderAction(action: () => Promise) { } export async function loadFolders() { - const isAdmin = getCurrentUser()?.role === "admin"; const result = await api("/api/folders"); appState.folders = result.folders; const root = appState.folders.find((folder) => folder.parent_id === null); @@ -73,16 +76,19 @@ export async function loadFolders() { data: result.tree, multiple: false }, - plugins: isAdmin ? ["contextmenu"] : [], + plugins: ["contextmenu"], contextmenu: { items(node: JsTreeNode) { + if (!isRealFolderId(node.id)) return {}; const folderId = Number(node.id); const folder = appState.folders.find((item) => item.id === folderId); - const isRoot = folder?.parent_id === null; + const canWrite = Boolean(folder?.permissions.write); + const isSystem = Boolean(folder?.is_system); return { create: { label: "新建目录", icon: "tree-menu-icon tree-menu-icon-add", + _disabled: !canWrite, action: () => runFolderAction(async () => { selectFolder(node); await createFolder(folderId); @@ -91,7 +97,7 @@ export async function loadFolders() { rename: { label: "重命名", icon: "tree-menu-icon tree-menu-icon-edit", - _disabled: isRoot, + _disabled: !canWrite || isSystem, action: () => runFolderAction(async () => { selectFolder(node); await renameFolder(folderId); @@ -100,7 +106,7 @@ export async function loadFolders() { remove: { label: "删除", icon: "tree-menu-icon tree-menu-icon-delete", - _disabled: isRoot, + _disabled: !canWrite || isSystem, action: () => runFolderAction(async () => { selectFolder(node); await deleteFolder(folderId); @@ -110,6 +116,7 @@ export async function loadFolders() { } } }).on("select_node.jstree", async (_event: JQuery.Event, data: { node: { id: string; text: string } }) => { + if (!isRealFolderId(data.node.id)) return; appState.selectedFolderId = Number(data.node.id); appState.selectedFolderName = appState.folders.find((folder) => folder.id === appState.selectedFolderId)?.name ?? data.node.text; appState.page = 1; diff --git a/web/src/pages/app/modules/models.ts b/web/src/pages/app/modules/models.ts index 961771e..32b86d4 100644 --- a/web/src/pages/app/modules/models.ts +++ b/web/src/pages/app/modules/models.ts @@ -10,6 +10,8 @@ type UploadFormState = { file: File | null; }; +const defaultOperationTree = JSON.stringify({ OperationTree: "[]" }); + export function bindModelActions() { const isAdmin = getCurrentUser()?.role === "admin"; if (isAdmin) { @@ -31,15 +33,16 @@ export function bindModelActions() { } }); - document.querySelector("#addModelBtn")?.addEventListener("click", async () => { - try { - await openUploadModelDialog(); - } catch (error) { - notifyError(error); - } - }); } + document.querySelector("#addModelBtn")?.addEventListener("click", async () => { + try { + await openUploadModelDialog(); + } catch (error) { + notifyError(error); + } + }); + document.querySelector("#brandFilter")!.addEventListener("change", async (event) => { appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value; appState.page = 1; @@ -77,6 +80,7 @@ export async function loadModels() { const grid = document.querySelector("#modelGrid"); if (!grid || !appState.selectedFolderId) return; await loadDictionaries(); + syncSelectedFolderActions(); document.querySelector("#folderCrumb")!.textContent = appState.selectedFolderName || "模型库"; const params = new URLSearchParams({ folderId: String(appState.selectedFolderId), @@ -144,7 +148,7 @@ function syncDictionarySelect(selector: string, items: { id: number; name: strin } function renderModelCard(item: ModelItem) { - const isAdmin = getCurrentUser()?.role === "admin"; + const canWrite = item.permissions.write; const prop = item.properties ?? {}; const thumb = item.thumbnail_url ? `` @@ -158,9 +162,9 @@ function renderModelCard(item: ModelItem) { ${thumb}
- ${isAdmin ? `` : ""} + ${canWrite ? `` : ""} - ${isAdmin ? `` : ""} + ${canWrite ? `` : ""}
@@ -174,6 +178,13 @@ function renderModelCard(item: ModelItem) { `; } +function syncSelectedFolderActions() { + const currentFolder = appState.folders.find((folder) => folder.id === appState.selectedFolderId); + const canWrite = Boolean(currentFolder?.permissions.write); + const addModelBtn = document.querySelector("#addModelBtn"); + if (addModelBtn) addModelBtn.hidden = !canWrite; +} + async function openUploadModelDialog() { if (!appState.selectedFolderId) { notify("请先选择目录"); @@ -226,6 +237,7 @@ async function openUploadModelDialog() { payload.set("name", name); payload.set("brandName", String(form.get("brandName") ?? "")); payload.set("typeName", String(form.get("typeName") ?? "")); + payload.set("operationTree", defaultOperationTree); payload.set("file", state.file); for (const key of ["model", "price", "weight"]) { payload.set(`prop.${key}`, String(form.get(key) ?? "")); diff --git a/web/src/pages/app/modules/preview.ts b/web/src/pages/app/modules/preview.ts index 698c2a3..d02a7c2 100644 --- a/web/src/pages/app/modules/preview.ts +++ b/web/src/pages/app/modules/preview.ts @@ -3,10 +3,11 @@ 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 type { OperationTree, OperationTreeDB } from "../../../types/OperationTree"; +import { P_OPERATION } from "../../../types/OPERATION_BaseClass"; 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; @@ -17,14 +18,21 @@ type PreviewRuntime = { resizeObserver: ResizeObserver; }; +type PreviewOperation = OperationTree & { + parsedCraftPlayData: P_OPERATION | null; + frameCount: number; + parseError: string | null; +}; + let runtime: PreviewRuntime | null = null; export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Promise | void) { disposePreview(); - const isAdmin = getCurrentUser()?.role === "admin"; + const canWrite = model.permissions.write; const url = model.file_url; + const operations = parseModelOperations(model.operation_tree); const popup = w2popup.open({ - title: `模型预览 - ${escapeHtml(model.name)}`, + title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`, width: 860, height: 620, modal: true, @@ -34,35 +42,22 @@ export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Prom
工艺播放 - 占位 -
-
- 模型大小 - ${formatBytes(model.file_size)} + ${operations.length} 项
    -
  • - -
  • -
  • - -
  • -
  • - -
  • + ${operationListHtml(operations)}
+
+ ${operationStatusHtml(operations[0] ?? null)} +
-
- 导入场景 - 占位 -
- ${isAdmin ? ` + ${canWrite ? `