diff --git a/.gitignore b/.gitignore index 71f8e07..60ba69e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ coverage/ # Prisma backend/prisma/migrations/ +tmpclaude-* diff --git a/backend/data/menus.json b/backend/data/menus.json index 860f3db..b6bd846 100644 --- a/backend/data/menus.json +++ b/backend/data/menus.json @@ -90,7 +90,7 @@ "icon": "UserAddOutlined", "component": "contests/registrations/Index", "sort": 2, - "permission": "registration:read" + "permission": "registration:create" }, { "name": "我的作品", @@ -98,7 +98,7 @@ "icon": "FileTextOutlined", "component": "contests/works/Index", "sort": 3, - "permission": "work:read" + "permission": "work:create" } ] }, @@ -141,15 +141,15 @@ "icon": "FileTextOutlined", "component": "contests/works/Index", "sort": 4, - "permission": "work:read" + "permission": "contest:read" }, { - "name": "评审任务", - "path": "/contests/review-tasks", + "name": "评审进度", + "path": "/contests/review-progress", "icon": "AuditOutlined", - "component": "contests/reviews/Tasks", + "component": "contests/reviews/Progress", "sort": 5, - "permission": "review:read" + "permission": "review-rule:read" }, { "name": "评审规则", @@ -192,23 +192,23 @@ "icon": "FileTextOutlined", "component": "homework/Index", "sort": 1, - "permission": "homework:read" - }, - { - "name": "作业批改", - "path": "/homework/review", - "icon": "EditOutlined", - "component": "homework/Submissions", - "sort": 2, - "permission": "homework-submission:read" + "permission": "homework:create" }, { "name": "评审规则", "path": "/homework/review-rules", "icon": "CheckCircleOutlined", "component": "homework/ReviewRules", - "sort": 3, + "sort": 2, "permission": "homework-review-rule:read" + }, + { + "name": "我的作业", + "path": "/homework/my", + "icon": "BookOutlined", + "component": "homework/StudentList", + "sort": 3, + "permission": "homework-submission:create" } ] }, diff --git a/backend/package.json b/backend/package.json index 49c90b2..c8ed635 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,8 @@ "cleanup:tenant-permissions": "ts-node scripts/cleanup-tenant-permissions.ts", "init:roles:super": "ts-node scripts/init-roles-permissions.ts --super", "init:roles": "ts-node scripts/init-roles-permissions.ts", - "init:roles:all": "ts-node scripts/init-roles-permissions.ts --all" + "init:roles:all": "ts-node scripts/init-roles-permissions.ts --all", + "init:tenant": "ts-node scripts/init-tenant.ts" }, "dependencies": { "@nestjs/common": "^10.3.3", diff --git a/backend/scripts/check-permissions.js b/backend/scripts/check-permissions.js new file mode 100644 index 0000000..7cca01d --- /dev/null +++ b/backend/scripts/check-permissions.js @@ -0,0 +1,60 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + // 查找香港小学租户 + const tenant = await prisma.tenant.findFirst({ + where: { code: 'school001' } + }); + + if (!tenant) { + console.log('租户 school001 不存在'); + return; + } + + console.log(`租户: ${tenant.name} (${tenant.code})\n`); + + // 查找该租户的 school_admin 角色 + const role = await prisma.role.findFirst({ + where: { tenantId: tenant.id, code: 'school_admin' }, + include: { + permissions: { + include: { + permission: true + } + } + } + }); + + if (!role) { + console.log('school_admin 角色不存在'); + return; + } + + console.log(`角色: ${role.name} (${role.code})`); + console.log(`权限数量: ${role.permissions.length}\n`); + + // 检查系统管理相关权限 + const systemPermissions = ['user:read', 'role:read', 'menu:read', 'permission:read']; + console.log('系统管理相关权限:'); + systemPermissions.forEach(code => { + const has = role.permissions.some(rp => rp.permission.code === code); + console.log(` ${code}: ${has ? '✓' : '✗'}`); + }); + + // 查找该租户的权限 + console.log('\n该租户所有权限:'); + const permissions = await prisma.permission.findMany({ + where: { tenantId: tenant.id } + }); + permissions.forEach(p => { + console.log(` ${p.code}`); + }); +} + +main() + .then(() => prisma.$disconnect()) + .catch(e => { + console.error(e); + prisma.$disconnect(); + }); diff --git a/backend/scripts/check-registrations.js b/backend/scripts/check-registrations.js new file mode 100644 index 0000000..0693d56 --- /dev/null +++ b/backend/scripts/check-registrations.js @@ -0,0 +1,64 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + // 查找3D打印作品大赛 + const contest = await prisma.contest.findFirst({ + where: { contestName: { contains: '3D打印' } } + }); + + if (!contest) { + console.log('未找到3D打印作品大赛'); + return; + } + + console.log(`赛事: ${contest.contestName} (ID: ${contest.id})\n`); + + // 查找该赛事的所有报名记录 + const registrations = await prisma.contestRegistration.findMany({ + where: { contestId: contest.id }, + include: { + user: true, + contest: true + } + }); + + console.log(`报名记录数量: ${registrations.length}\n`); + + if (registrations.length > 0) { + console.log('报名记录详情:'); + registrations.forEach(r => { + console.log(` ID: ${r.id}, 用户: ${r.user?.username || 'N/A'}, 租户ID: ${r.tenantId}, 状态: ${r.status}`); + }); + } + + // 查找 xuesheng1 用户 + const user = await prisma.user.findFirst({ + where: { username: 'xuesheng1' } + }); + + if (user) { + console.log(`\nxuesheng1 用户信息:`); + console.log(` ID: ${user.id}, 租户ID: ${user.tenantId}`); + + // 查找该用户的所有报名记录 + const userRegistrations = await prisma.contestRegistration.findMany({ + where: { userId: user.id }, + include: { contest: true } + }); + + console.log(`\nxuesheng1 的所有报名记录 (${userRegistrations.length}):`); + userRegistrations.forEach(r => { + console.log(` 赛事: ${r.contest?.contestName}, 状态: ${r.status}, 租户ID: ${r.tenantId}`); + }); + } else { + console.log('\n未找到 xuesheng1 用户'); + } +} + +main() + .then(() => prisma.$disconnect()) + .catch(e => { + console.error(e); + prisma.$disconnect(); + }); diff --git a/backend/scripts/check-student-permissions.js b/backend/scripts/check-student-permissions.js new file mode 100644 index 0000000..7ed8a68 --- /dev/null +++ b/backend/scripts/check-student-permissions.js @@ -0,0 +1,52 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + // 查找香港小学租户 + const tenant = await prisma.tenant.findFirst({ + where: { code: 'school001' } + }); + + if (!tenant) { + console.log('租户 school001 不存在'); + return; + } + + console.log(`租户: ${tenant.name} (${tenant.code})\n`); + + // 查找该租户的 student 角色 + const role = await prisma.role.findFirst({ + where: { tenantId: tenant.id, code: 'student' }, + include: { + permissions: { + include: { + permission: true + } + } + } + }); + + if (!role) { + console.log('student 角色不存在'); + return; + } + + console.log(`角色: ${role.name} (${role.code})`); + console.log(`权限数量: ${role.permissions.length}\n`); + + console.log('学生角色所有权限:'); + role.permissions.forEach(rp => { + console.log(` ${rp.permission.code}`); + }); + + // 检查是否有 registration:read 权限 + const hasRegistrationRead = role.permissions.some(rp => rp.permission.code === 'registration:read'); + console.log(`\nregistration:read 权限: ${hasRegistrationRead ? '有' : '无'}`); +} + +main() + .then(() => prisma.$disconnect()) + .catch(e => { + console.error(e); + prisma.$disconnect(); + }); diff --git a/backend/scripts/check-users.js b/backend/scripts/check-users.js new file mode 100644 index 0000000..9e5ec50 --- /dev/null +++ b/backend/scripts/check-users.js @@ -0,0 +1,22 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + const users = await prisma.user.findMany({ + include: { tenant: true }, + }); + + console.log('All users:'); + users.forEach(u => { + const tenantName = u.tenant ? u.tenant.name : 'N/A'; + const tenantCode = u.tenant ? u.tenant.code : 'N/A'; + console.log(` Tenant: ${tenantName} (${tenantCode}), User: ${u.username}, ID: ${u.id}`); + }); +} + +main() + .then(() => prisma.$disconnect()) + .catch(e => { + console.error(e); + prisma.$disconnect(); + }); diff --git a/backend/scripts/init-admin.ts b/backend/scripts/init-admin.ts index 2b9419b..3f54df3 100644 --- a/backend/scripts/init-admin.ts +++ b/backend/scripts/init-admin.ts @@ -1,31 +1,23 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck -// 加载环境变量(必须在其他导入之前) +// 初始化超级管理员脚本(支持多租户) import * as dotenv from 'dotenv'; import * as path from 'path'; // 根据 NODE_ENV 加载对应的环境配置文件 const nodeEnv = process.env.NODE_ENV || 'development'; const envFile = `.env.${nodeEnv}`; -// scripts 目录的父目录就是 backend 目录 const backendDir = path.resolve(__dirname, '..'); const envPath = path.resolve(backendDir, envFile); -// 尝试加载环境特定的配置文件 dotenv.config({ path: envPath }); -// 如果环境特定文件不存在,尝试加载默认的 .env 文件 if (!process.env.DATABASE_URL) { dotenv.config({ path: path.resolve(backendDir, '.env') }); } -// 验证必要的环境变量 if (!process.env.DATABASE_URL) { console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); - console.error(` 请确保存在以下文件之一:`); - console.error(` - ${envPath}`); - console.error(` - ${path.resolve(backendDir, '.env')}`); - console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); process.exit(1); } @@ -34,478 +26,237 @@ import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); -// 定义所有基础权限 +// 超级管理员基础权限 const permissions = [ - // 用户管理权限 - { - code: 'user:create', - resource: 'user', - action: 'create', - name: '创建用户', - description: '允许创建新用户', - }, - { - code: 'user:read', - resource: 'user', - action: 'read', - name: '查看用户', - description: '允许查看用户列表和详情', - }, - { - code: 'user:update', - resource: 'user', - action: 'update', - name: '更新用户', - description: '允许更新用户信息', - }, - { - code: 'user:delete', - resource: 'user', - action: 'delete', - name: '删除用户', - description: '允许删除用户', - }, - - // 角色管理权限 - { - code: 'role:create', - resource: 'role', - action: 'create', - name: '创建角色', - description: '允许创建新角色', - }, - { - code: 'role:read', - resource: 'role', - action: 'read', - name: '查看角色', - description: '允许查看角色列表和详情', - }, - { - code: 'role:update', - resource: 'role', - action: 'update', - name: '更新角色', - description: '允许更新角色信息', - }, - { - code: 'role:delete', - resource: 'role', - action: 'delete', - name: '删除角色', - description: '允许删除角色', - }, - { - code: 'role:assign', - resource: 'role', - action: 'assign', - name: '分配角色', - description: '允许给用户分配角色', - }, - - // 权限管理权限 - { - code: 'permission:create', - resource: 'permission', - action: 'create', - name: '创建权限', - description: '允许创建新权限', - }, - { - code: 'permission:read', - resource: 'permission', - action: 'read', - name: '查看权限', - description: '允许查看权限列表和详情', - }, - { - code: 'permission:update', - resource: 'permission', - action: 'update', - name: '更新权限', - description: '允许更新权限信息', - }, - { - code: 'permission:delete', - resource: 'permission', - action: 'delete', - name: '删除权限', - description: '允许删除权限', - }, - - // 菜单管理权限 - { - code: 'menu:create', - resource: 'menu', - action: 'create', - name: '创建菜单', - description: '允许创建新菜单', - }, - { - code: 'menu:read', - resource: 'menu', - action: 'read', - name: '查看菜单', - description: '允许查看菜单列表和详情', - }, - { - code: 'menu:update', - resource: 'menu', - action: 'update', - name: '更新菜单', - description: '允许更新菜单信息', - }, - { - code: 'menu:delete', - resource: 'menu', - action: 'delete', - name: '删除菜单', - description: '允许删除菜单', - }, - - // 数据字典权限 - { - code: 'dict:create', - resource: 'dict', - action: 'create', - name: '创建字典', - description: '允许创建新字典', - }, - { - code: 'dict:read', - resource: 'dict', - action: 'read', - name: '查看字典', - description: '允许查看字典列表和详情', - }, - { - code: 'dict:update', - resource: 'dict', - action: 'update', - name: '更新字典', - description: '允许更新字典信息', - }, - { - code: 'dict:delete', - resource: 'dict', - action: 'delete', - name: '删除字典', - description: '允许删除字典', - }, - - // 系统配置权限 - { - code: 'config:create', - resource: 'config', - action: 'create', - name: '创建配置', - description: '允许创建新配置', - }, - { - code: 'config:read', - resource: 'config', - action: 'read', - name: '查看配置', - description: '允许查看配置列表和详情', - }, - { - code: 'config:update', - resource: 'config', - action: 'update', - name: '更新配置', - description: '允许更新配置信息', - }, - { - code: 'config:delete', - resource: 'config', - action: 'delete', - name: '删除配置', - description: '允许删除配置', - }, - - // 日志管理权限 - { - code: 'log:read', - resource: 'log', - action: 'read', - name: '查看日志', - description: '允许查看系统日志', - }, - { - code: 'log:delete', - resource: 'log', - action: 'delete', - name: '删除日志', - description: '允许删除系统日志', - }, - - // 用户密码管理权限 - { - code: 'user:password:update', - resource: 'user', - action: 'password:update', - name: '修改用户密码', - description: '允许修改用户密码', - }, -]; - -// 根据路由配置定义的菜单数据 -const menus = [ - // 顶级菜单:仪表盘 - { - name: '仪表盘', - path: '/dashboard', - icon: 'DashboardOutlined', - component: 'dashboard/Index', - parentId: null, - sort: 1, - }, - // 父菜单:系统管理 - { - name: '系统管理', - path: '/system', - icon: 'SettingOutlined', - component: null, // 父菜单不需要组件 - parentId: null, - sort: 10, - children: [ - { - name: '用户管理', - path: '/system/users', - icon: 'UserOutlined', - component: 'system/users/Index', - sort: 1, - }, - { - name: '角色管理', - path: '/system/roles', - icon: 'TeamOutlined', - component: 'system/roles/Index', - sort: 2, - }, - { - name: '菜单管理', - path: '/system/menus', - icon: 'MenuOutlined', - component: 'system/menus/Index', - sort: 3, - }, - { - name: '数据字典', - path: '/system/dict', - icon: 'BookOutlined', - component: 'system/dict/Index', - sort: 4, - }, - { - name: '系统配置', - path: '/system/config', - icon: 'ToolOutlined', - component: 'system/config/Index', - sort: 5, - }, - { - name: '日志记录', - path: '/system/logs', - icon: 'FileTextOutlined', - component: 'system/logs/Index', - sort: 6, - }, - ], - }, + // 工作台 + { code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' }, + // 用户管理 + { code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' }, + { code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' }, + { code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' }, + { code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' }, + // 角色管理 + { code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' }, + { code: 'role:read', resource: 'role', action: 'read', name: '查看角色', description: '允许查看角色列表和详情' }, + { code: 'role:update', resource: 'role', action: 'update', name: '更新角色', description: '允许更新角色信息' }, + { code: 'role:delete', resource: 'role', action: 'delete', name: '删除角色', description: '允许删除角色' }, + { code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' }, + // 权限管理 + { code: 'permission:read', resource: 'permission', action: 'read', name: '查看权限', description: '允许查看权限列表' }, + // 菜单管理 + { code: 'menu:read', resource: 'menu', action: 'read', name: '查看菜单', description: '允许查看菜单列表' }, + // 租户管理 + { code: 'tenant:create', resource: 'tenant', action: 'create', name: '创建租户', description: '允许创建租户' }, + { code: 'tenant:read', resource: 'tenant', action: 'read', name: '查看租户', description: '允许查看租户列表' }, + { code: 'tenant:update', resource: 'tenant', action: 'update', name: '更新租户', description: '允许更新租户信息' }, + { code: 'tenant:delete', resource: 'tenant', action: 'delete', name: '删除租户', description: '允许删除租户' }, + // 赛事管理 + { code: 'contest:create', resource: 'contest', action: 'create', name: '创建赛事', description: '允许创建赛事' }, + { code: 'contest:read', resource: 'contest', action: 'read', name: '查看赛事', description: '允许查看赛事列表' }, + { code: 'contest:update', resource: 'contest', action: 'update', name: '更新赛事', description: '允许更新赛事信息' }, + { code: 'contest:delete', resource: 'contest', action: 'delete', name: '删除赛事', description: '允许删除赛事' }, + { code: 'contest:publish', resource: 'contest', action: 'publish', name: '发布赛事', description: '允许发布赛事' }, + { code: 'contest:finish', resource: 'contest', action: 'finish', name: '结束赛事', description: '允许结束赛事' }, + // 评审规则 + { code: 'review-rule:create', resource: 'review-rule', action: 'create', name: '创建评审规则', description: '允许创建评审规则' }, + { code: 'review-rule:read', resource: 'review-rule', action: 'read', name: '查看评审规则', description: '允许查看评审规则' }, + { code: 'review-rule:update', resource: 'review-rule', action: 'update', name: '更新评审规则', description: '允许更新评审规则' }, + { code: 'review-rule:delete', resource: 'review-rule', action: 'delete', name: '删除评审规则', description: '允许删除评审规则' }, + // 评委管理 + { code: 'judge:create', resource: 'judge', action: 'create', name: '添加评委', description: '允许添加评委' }, + { code: 'judge:read', resource: 'judge', action: 'read', name: '查看评委', description: '允许查看评委列表' }, + { code: 'judge:update', resource: 'judge', action: 'update', name: '更新评委', description: '允许更新评委信息' }, + { code: 'judge:delete', resource: 'judge', action: 'delete', name: '删除评委', description: '允许删除评委' }, + { code: 'judge:assign', resource: 'judge', action: 'assign', name: '分配评委', description: '允许为赛事分配评委' }, + // 报名管理 + { code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' }, + { code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' }, + // 作品管理 + { code: 'work:read', resource: 'work', action: 'read', name: '查看作品', description: '允许查看参赛作品' }, + // 公告管理 + { code: 'notice:create', resource: 'notice', action: 'create', name: '创建公告', description: '允许创建赛事公告' }, + { code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看赛事公告' }, + { code: 'notice:update', resource: 'notice', action: 'update', name: '更新公告', description: '允许更新公告信息' }, + { code: 'notice:delete', resource: 'notice', action: 'delete', name: '删除公告', description: '允许删除公告' }, + // 系统管理 + { code: 'dict:create', resource: 'dict', action: 'create', name: '创建字典', description: '允许创建新字典' }, + { code: 'dict:read', resource: 'dict', action: 'read', name: '查看字典', description: '允许查看字典列表和详情' }, + { code: 'dict:update', resource: 'dict', action: 'update', name: '更新字典', description: '允许更新字典信息' }, + { code: 'dict:delete', resource: 'dict', action: 'delete', name: '删除字典', description: '允许删除字典' }, + { code: 'config:create', resource: 'config', action: 'create', name: '创建配置', description: '允许创建新配置' }, + { code: 'config:read', resource: 'config', action: 'read', name: '查看配置', description: '允许查看配置列表和详情' }, + { code: 'config:update', resource: 'config', action: 'update', name: '更新配置', description: '允许更新配置信息' }, + { code: 'config:delete', resource: 'config', action: 'delete', name: '删除配置', description: '允许删除配置' }, + { code: 'log:read', resource: 'log', action: 'read', name: '查看日志', description: '允许查看系统日志' }, + { code: 'log:delete', resource: 'log', action: 'delete', name: '删除日志', description: '允许删除系统日志' }, ]; async function initAdmin() { try { console.log('🚀 开始初始化超级管理员...\n'); - // 1. 创建或获取所有权限 - console.log('📝 步骤 1: 创建基础权限...'); - const createdPermissions = []; - for (const perm of permissions) { - const permission = await prisma.permission.upsert({ - where: { code: perm.code }, - update: perm, - create: perm, + // 1. 获取或创建超级租户 + console.log('🏢 步骤 1: 获取超级租户...'); + let superTenant = await prisma.tenant.findFirst({ + where: { isSuper: 1, validState: 1 } + }); + + if (!superTenant) { + console.log(' 未找到超级租户,正在创建...'); + superTenant = await prisma.tenant.create({ + data: { + name: '超级租户', + code: 'super', + isSuper: 1, + validState: 1, + } }); - createdPermissions.push(permission); - console.log(` ✓ ${perm.code} - ${perm.name}`); + console.log(` ✓ 创建超级租户: ${superTenant.name} (${superTenant.code})`); + } else { + console.log(` ✓ 找到超级租户: ${superTenant.name} (ID: ${superTenant.id})`); } - console.log(`✅ 共创建/更新 ${createdPermissions.length} 个权限\n`); - // 2. 创建或获取超级管理员角色 - console.log('👤 步骤 2: 创建超级管理员角色...'); - const adminRole = await prisma.role.upsert({ - where: { code: 'super_admin' }, - update: { - name: '超级管理员', - description: '拥有系统所有权限的超级管理员角色', - }, - create: { - name: '超级管理员', - code: 'super_admin', - description: '拥有系统所有权限的超级管理员角色', - permissions: { - create: createdPermissions.map((perm) => ({ - permission: { connect: { id: perm.id } }, - })), - }, - }, - }); - console.log( - `✅ 超级管理员角色已创建/更新: ${adminRole.name} (${adminRole.code})\n`, - ); + const tenantId = superTenant.id; - // 3. 创建或获取 admin 用户 - console.log('👤 步骤 3: 创建 admin 用户...'); - const hashedPassword = await bcrypt.hash('cms@admin', 10); + // 2. 创建权限 + console.log('\n📝 步骤 2: 创建基础权限...'); + const createdPermissions: any[] = []; - const adminUser = await prisma.user.upsert({ - where: { username: 'admin' }, - update: { - password: hashedPassword, - nickname: '超级管理员', - validState: 1, - }, - create: { - username: 'admin', - password: hashedPassword, - nickname: '超级管理员', - email: 'admin@example.com', - validState: 1, - }, - }); - console.log( - `✅ 用户已创建/更新: ${adminUser.username} (${adminUser.nickname})\n`, - ); - - // 4. 给 admin 用户分配超级管理员角色 - console.log('🔗 步骤 4: 分配角色...'); - await prisma.userRole.upsert({ - where: { - userId_roleId: { - userId: adminUser.id, - roleId: adminRole.id, - }, - }, - update: {}, - create: { - user: { connect: { id: adminUser.id } }, - role: { connect: { id: adminRole.id } }, - }, - }); - console.log(`✅ 角色分配成功\n`); - - // 5. 初始化菜单数据 - console.log('📋 步骤 5: 初始化菜单数据...'); - - // 递归创建菜单 - async function createMenu(menuData: any, parentId: number | null = null) { - const { children, ...menuFields } = menuData; - - // 查找是否已存在相同名称和父菜单的菜单 - const existingMenu = await prisma.menu.findFirst({ - where: { - name: menuFields.name, - parentId: parentId, - }, + for (const perm of permissions) { + // 使用 tenantId + code 作为唯一约束 + let permission = await prisma.permission.findFirst({ + where: { tenantId, code: perm.code } }); - let menu; - if (existingMenu) { - // 更新现有菜单 - menu = await prisma.menu.update({ - where: { id: existingMenu.id }, - data: { - name: menuFields.name, - path: menuFields.path || null, - icon: menuFields.icon || null, - component: menuFields.component || null, - parentId: parentId, - sort: menuFields.sort || 0, - validState: 1, - }, + if (permission) { + permission = await prisma.permission.update({ + where: { id: permission.id }, + data: { ...perm, tenantId } }); } else { - // 创建新菜单 - menu = await prisma.menu.create({ - data: { - name: menuFields.name, - path: menuFields.path || null, - icon: menuFields.icon || null, - component: menuFields.component || null, - parentId: parentId, - sort: menuFields.sort || 0, - validState: 1, - }, + permission = await prisma.permission.create({ + data: { ...perm, tenantId, validState: 1 } }); } - // 如果有子菜单,递归创建 - if (children && children.length > 0) { - for (const child of children) { - await createMenu(child, menu.id); + createdPermissions.push(permission); + } + console.log(` ✓ 共创建/更新 ${createdPermissions.length} 个权限`); + + // 3. 创建超级管理员角色 + console.log('\n👤 步骤 3: 创建超级管理员角色...'); + let adminRole = await prisma.role.findFirst({ + where: { tenantId, code: 'super_admin' } + }); + + if (adminRole) { + adminRole = await prisma.role.update({ + where: { id: adminRole.id }, + data: { + name: '超级管理员', + description: '拥有系统所有权限的超级管理员角色', } - } - - return menu; - } - - // 创建所有菜单 - for (const menu of menus) { - await createMenu(menu); - } - - // 统计菜单数量 - const menuCount = await prisma.menu.count(); - const topLevelMenuCount = await prisma.menu.count({ - where: { parentId: null }, - }); - - console.log( - `✅ 菜单初始化完成: 共 ${menuCount} 个菜单(${topLevelMenuCount} 个顶级菜单)\n`, - ); - - // 6. 验证结果 - console.log('🔍 步骤 6: 验证结果...'); - const userWithRoles = await prisma.user.findUnique({ - where: { id: adminUser.id }, - include: { - roles: { - include: { - role: { - include: { - permissions: { - include: { - permission: true, - }, - }, - }, - }, - }, - }, - }, - }); - - const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || []; - const permissionCodes = new Set(); - userWithRoles?.roles.forEach((ur) => { - ur.role.permissions.forEach((rp) => { - permissionCodes.add(rp.permission.code); }); + console.log(` ✓ 更新角色: ${adminRole.name}`); + } else { + adminRole = await prisma.role.create({ + data: { + tenantId, + name: '超级管理员', + code: 'super_admin', + description: '拥有系统所有权限的超级管理员角色', + validState: 1, + } + }); + console.log(` ✓ 创建角色: ${adminRole.name}`); + } + + // 4. 分配权限给角色 + console.log('\n🔗 步骤 4: 分配权限给角色...'); + // 先获取已有的角色权限 + const existingRolePermissions = await prisma.rolePermission.findMany({ + where: { roleId: adminRole.id }, + select: { permissionId: true } + }); + const existingPermissionIds = new Set(existingRolePermissions.map(rp => rp.permissionId)); + + let addedCount = 0; + for (const perm of createdPermissions) { + if (!existingPermissionIds.has(perm.id)) { + await prisma.rolePermission.create({ + data: { + roleId: adminRole.id, + permissionId: perm.id, + } + }); + addedCount++; + } + } + console.log(` ✓ 新增 ${addedCount} 个权限分配`); + + // 5. 创建 admin 用户 + console.log('\n👤 步骤 5: 创建 admin 用户...'); + const password = `admin@${superTenant.code}`; + const hashedPassword = await bcrypt.hash(password, 10); + + let adminUser = await prisma.user.findFirst({ + where: { tenantId, username: 'admin' } }); - console.log(`\n📊 初始化结果:`); - console.log(` 用户名: ${adminUser.username}`); - console.log(` 昵称: ${adminUser.nickname}`); - console.log(` 密码: cms@admin`); - console.log(` 角色: ${roleCodes.join(', ')}`); - console.log(` 权限数量: ${permissionCodes.size}`); - console.log(` 菜单数量: ${menuCount} (${topLevelMenuCount} 个顶级菜单)`); - console.log(`\n✅ 超级管理员和菜单数据初始化完成!`); - console.log(`\n💡 现在可以使用以下凭据登录:`); - console.log(` 用户名: admin`); - console.log(` 密码: cms@admin`); + if (adminUser) { + adminUser = await prisma.user.update({ + where: { id: adminUser.id }, + data: { + password: hashedPassword, + nickname: '超级管理员', + validState: 1, + } + }); + console.log(` ✓ 更新用户: ${adminUser.username}`); + } else { + adminUser = await prisma.user.create({ + data: { + tenantId, + username: 'admin', + password: hashedPassword, + nickname: '超级管理员', + validState: 1, + } + }); + console.log(` ✓ 创建用户: ${adminUser.username}`); + } + + // 6. 给用户分配角色 + console.log('\n🔗 步骤 6: 分配角色给用户...'); + const existingUserRole = await prisma.userRole.findFirst({ + where: { userId: adminUser.id, roleId: adminRole.id } + }); + + if (!existingUserRole) { + await prisma.userRole.create({ + data: { + userId: adminUser.id, + roleId: adminRole.id, + } + }); + console.log(` ✓ 分配角色: ${adminRole.name}`); + } else { + console.log(` ✓ 角色已分配: ${adminRole.name}`); + } + + // 7. 输出结果 + console.log('\n' + '='.repeat(50)); + console.log('🎉 超级管理员初始化完成!'); + console.log('='.repeat(50)); + console.log(` 租户编码: ${superTenant.code}`); + console.log(` 用户名: admin`); + console.log(` 密码: ${password}`); + console.log(` 角色: ${adminRole.name}`); + console.log(` 权限数量: ${createdPermissions.length}`); + console.log('='.repeat(50)); + console.log('\n💡 提示: 请运行以下命令初始化菜单:'); + console.log(' npm run init:menus'); + } catch (error) { console.error('❌ 初始化失败:', error); throw error; @@ -517,7 +268,7 @@ async function initAdmin() { // 执行初始化 initAdmin() .then(() => { - console.log('\n🎉 初始化脚本执行完成!'); + console.log('\n✅ 初始化脚本执行完成!'); process.exit(0); }) .catch((error) => { diff --git a/backend/scripts/init-menus.ts b/backend/scripts/init-menus.ts index f5501b9..5589645 100644 --- a/backend/scripts/init-menus.ts +++ b/backend/scripts/init-menus.ts @@ -47,6 +47,15 @@ const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8')); // 超级租户可见的菜单名称 const SUPER_TENANT_MENUS = ['工作台', '赛事活动', '赛事管理', '系统管理']; +// 普通租户可见的菜单名称 +const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '赛事活动', '作业管理', '系统管理']; + +// 普通租户在系统管理下排除的子菜单(只保留用户管理和角色管理) +const NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理']; + +// 普通租户在赛事活动下排除的子菜单(只保留活动列表) +const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['我的报名', '我的作品']; + async function initMenus() { try { console.log('🚀 开始初始化菜单数据...\n'); @@ -179,7 +188,7 @@ async function initMenus() { } else { console.log(` 找到 ${allTenants.length} 个租户\n`); - // 获取超级租户专属菜单ID(工作台、赛事管理、系统管理及其子菜单) + // 获取超级租户菜单ID(工作台、赛事活动、赛事管理、系统管理及其子菜单) const superTenantMenuIds = new Set(); for (const menu of allMenus) { // 顶级菜单 @@ -195,13 +204,37 @@ async function initMenus() { } } + // 获取普通租户菜单ID(工作台、学校管理、赛事活动、作业管理、部分系统管理) + const normalTenantMenuIds = new Set(); + for (const menu of allMenus) { + // 顶级菜单 + if (!menu.parentId && NORMAL_TENANT_MENUS.includes(menu.name)) { + normalTenantMenuIds.add(menu.id); + } + // 子菜单 + if (menu.parentId) { + const parentMenu = allMenus.find(m => m.id === menu.parentId); + if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) { + // 系统管理下排除部分子菜单 + if (parentMenu.name === '系统管理' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { + continue; // 跳过排除的菜单 + } + // 赛事活动下排除部分子菜单(只保留活动列表) + if (parentMenu.name === '赛事活动' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) { + continue; // 跳过排除的菜单 + } + normalTenantMenuIds.add(menu.id); + } + } + } + for (const tenant of allTenants) { const isSuperTenant = tenant.isSuper === 1; // 确定要分配的菜单 const menusToAssign = isSuperTenant ? allMenus.filter(m => superTenantMenuIds.has(m.id)) - : allMenus; + : allMenus.filter(m => normalTenantMenuIds.has(m.id)); // 为租户分配菜单 let addedMenuCount = 0; diff --git a/backend/scripts/init-roles-permissions.ts b/backend/scripts/init-roles-permissions.ts index cd97e8b..6e2cd1f 100644 --- a/backend/scripts/init-roles-permissions.ts +++ b/backend/scripts/init-roles-permissions.ts @@ -236,7 +236,7 @@ const normalTenantRoles = [ permissions: [ 'workbench:read', 'user:create', 'user:read', 'user:update', 'user:delete', - 'role:read', + 'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign', 'permission:read', 'menu:read', // 学校管理 diff --git a/backend/scripts/init-tenant.ts b/backend/scripts/init-tenant.ts new file mode 100644 index 0000000..9d3e2b0 --- /dev/null +++ b/backend/scripts/init-tenant.ts @@ -0,0 +1,429 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// 初始化普通租户脚本(包含角色) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +dotenv.config({ path: envPath }); + +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import * as readline from 'readline'; + +const prisma = new PrismaClient(); + +// ============================================ +// 权限定义 +// ============================================ +const allPermissions = [ + // 工作台 + { code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' }, + + // 用户管理 + { code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' }, + { code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' }, + { code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' }, + { code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' }, + + // 角色管理 + { code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' }, + { code: 'role:read', resource: 'role', action: 'read', name: '查看角色', description: '允许查看角色列表和详情' }, + { code: 'role:update', resource: 'role', action: 'update', name: '更新角色', description: '允许更新角色信息' }, + { code: 'role:delete', resource: 'role', action: 'delete', name: '删除角色', description: '允许删除角色' }, + { code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' }, + + // 权限管理 + { code: 'permission:read', resource: 'permission', action: 'read', name: '查看权限', description: '允许查看权限列表' }, + + // 菜单管理 + { code: 'menu:read', resource: 'menu', action: 'read', name: '查看菜单', description: '允许查看菜单列表' }, + + // 学校管理 + { code: 'school:create', resource: 'school', action: 'create', name: '创建学校', description: '允许创建学校信息' }, + { code: 'school:read', resource: 'school', action: 'read', name: '查看学校', description: '允许查看学校信息' }, + { code: 'school:update', resource: 'school', action: 'update', name: '更新学校', description: '允许更新学校信息' }, + { code: 'school:delete', resource: 'school', action: 'delete', name: '删除学校', description: '允许删除学校信息' }, + + // 部门管理 + { code: 'department:create', resource: 'department', action: 'create', name: '创建部门', description: '允许创建部门' }, + { code: 'department:read', resource: 'department', action: 'read', name: '查看部门', description: '允许查看部门列表' }, + { code: 'department:update', resource: 'department', action: 'update', name: '更新部门', description: '允许更新部门信息' }, + { code: 'department:delete', resource: 'department', action: 'delete', name: '删除部门', description: '允许删除部门' }, + + // 年级管理 + { code: 'grade:create', resource: 'grade', action: 'create', name: '创建年级', description: '允许创建年级' }, + { code: 'grade:read', resource: 'grade', action: 'read', name: '查看年级', description: '允许查看年级列表' }, + { code: 'grade:update', resource: 'grade', action: 'update', name: '更新年级', description: '允许更新年级信息' }, + { code: 'grade:delete', resource: 'grade', action: 'delete', name: '删除年级', description: '允许删除年级' }, + + // 班级管理 + { code: 'class:create', resource: 'class', action: 'create', name: '创建班级', description: '允许创建班级' }, + { code: 'class:read', resource: 'class', action: 'read', name: '查看班级', description: '允许查看班级列表' }, + { code: 'class:update', resource: 'class', action: 'update', name: '更新班级', description: '允许更新班级信息' }, + { code: 'class:delete', resource: 'class', action: 'delete', name: '删除班级', description: '允许删除班级' }, + + // 教师管理 + { code: 'teacher:create', resource: 'teacher', action: 'create', name: '创建教师', description: '允许创建教师' }, + { code: 'teacher:read', resource: 'teacher', action: 'read', name: '查看教师', description: '允许查看教师列表' }, + { code: 'teacher:update', resource: 'teacher', action: 'update', name: '更新教师', description: '允许更新教师信息' }, + { code: 'teacher:delete', resource: 'teacher', action: 'delete', name: '删除教师', description: '允许删除教师' }, + + // 学生管理 + { code: 'student:create', resource: 'student', action: 'create', name: '创建学生', description: '允许创建学生' }, + { code: 'student:read', resource: 'student', action: 'read', name: '查看学生', description: '允许查看学生列表' }, + { code: 'student:update', resource: 'student', action: 'update', name: '更新学生', description: '允许更新学生信息' }, + { code: 'student:delete', resource: 'student', action: 'delete', name: '删除学生', description: '允许删除学生' }, + + // 赛事活动(参与者权限) + { code: 'activity:read', resource: 'activity', action: 'read', name: '查看赛事活动', description: '允许查看已发布的赛事活动' }, + { code: 'activity:guidance', resource: 'activity', action: 'guidance', name: '指导学生', description: '允许指导学生参赛' }, + + // 赛事报名 + { code: 'registration:create', resource: 'registration', action: 'create', name: '创建报名', description: '允许报名赛事' }, + { code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' }, + { code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' }, + { code: 'registration:delete', resource: 'registration', action: 'delete', name: '取消报名', description: '允许取消报名' }, + + // 参赛作品 + { code: 'work:create', resource: 'work', action: 'create', name: '上传作品', description: '允许上传参赛作品' }, + { code: 'work:read', resource: 'work', action: 'read', name: '查看作品', description: '允许查看参赛作品' }, + { code: 'work:update', resource: 'work', action: 'update', name: '更新作品', description: '允许更新作品信息' }, + { code: 'work:delete', resource: 'work', action: 'delete', name: '删除作品', description: '允许删除作品' }, + { code: 'work:submit', resource: 'work', action: 'submit', name: '提交作品', description: '允许提交作品' }, + + // 赛事公告 + { code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看赛事公告' }, + + // 作业管理 + { code: 'homework:create', resource: 'homework', action: 'create', name: '创建作业', description: '允许创建作业' }, + { code: 'homework:read', resource: 'homework', action: 'read', name: '查看作业', description: '允许查看作业列表' }, + { code: 'homework:update', resource: 'homework', action: 'update', name: '更新作业', description: '允许更新作业信息' }, + { code: 'homework:delete', resource: 'homework', action: 'delete', name: '删除作业', description: '允许删除作业' }, + { code: 'homework:publish', resource: 'homework', action: 'publish', name: '发布作业', description: '允许发布作业' }, + + // 作业提交 + { code: 'homework-submission:create', resource: 'homework-submission', action: 'create', name: '提交作业', description: '允许提交作业' }, + { code: 'homework-submission:read', resource: 'homework-submission', action: 'read', name: '查看作业提交', description: '允许查看作业提交记录' }, + { code: 'homework-submission:update', resource: 'homework-submission', action: 'update', name: '更新作业提交', description: '允许更新提交的作业' }, + + // 作业评审规则 + { code: 'homework-review-rule:create', resource: 'homework-review-rule', action: 'create', name: '创建作业评审规则', description: '允许创建作业评审规则' }, + { code: 'homework-review-rule:read', resource: 'homework-review-rule', action: 'read', name: '查看作业评审规则', description: '允许查看作业评审规则' }, + { code: 'homework-review-rule:update', resource: 'homework-review-rule', action: 'update', name: '更新作业评审规则', description: '允许更新作业评审规则' }, + { code: 'homework-review-rule:delete', resource: 'homework-review-rule', action: 'delete', name: '删除作业评审规则', description: '允许删除作业评审规则' }, + + // 作业评分 + { code: 'homework-score:create', resource: 'homework-score', action: 'create', name: '作业评分', description: '允许对作业评分' }, + { code: 'homework-score:read', resource: 'homework-score', action: 'read', name: '查看作业评分', description: '允许查看作业评分' }, +]; + +// ============================================ +// 角色定义 +// ============================================ +const normalTenantRoles = [ + { + code: 'school_admin', + name: '学校管理员', + description: '学校管理员,管理学校信息、教师、学生等', + permissions: [ + 'workbench:read', + 'user:create', 'user:read', 'user:update', 'user:delete', + 'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign', + 'permission:read', + 'menu:read', + // 学校管理 + 'school:create', 'school:read', 'school:update', 'school:delete', + 'department:create', 'department:read', 'department:update', 'department:delete', + 'grade:create', 'grade:read', 'grade:update', 'grade:delete', + 'class:create', 'class:read', 'class:update', 'class:delete', + 'teacher:create', 'teacher:read', 'teacher:update', 'teacher:delete', + 'student:create', 'student:read', 'student:update', 'student:delete', + // 赛事活动 + 'activity:read', + 'notice:read', + // 可以查看报名和作品 + 'registration:read', + 'work:read', + // 作业管理 + 'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish', + 'homework-submission:read', + 'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete', + 'homework-score:read', + ], + }, + { + code: 'teacher', + name: '教师', + description: '教师角色,可以报名赛事、指导学生、管理作业', + permissions: [ + 'workbench:read', + // 查看基础信息 + 'grade:read', + 'class:read', + 'student:read', + // 赛事活动 + 'activity:read', + 'activity:guidance', + 'notice:read', + 'registration:create', 'registration:read', 'registration:update', 'registration:delete', + 'work:create', 'work:read', 'work:update', 'work:submit', + // 作业管理 + 'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish', + 'homework-submission:read', + 'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete', + 'homework-score:create', 'homework-score:read', + ], + }, + { + code: 'student', + name: '学生', + description: '学生角色,可以查看赛事、上传作品、提交作业', + permissions: [ + 'workbench:read', + // 赛事活动 + 'activity:read', + 'notice:read', + 'registration:read', + 'work:create', 'work:read', 'work:update', 'work:submit', + // 作业 + 'homework:read', + 'homework-submission:create', 'homework-submission:read', 'homework-submission:update', + 'homework-score:read', + ], + }, +]; + +// 创建 readline 接口用于用户输入 +function createReadlineInterface(): readline.Interface { + return readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); +} + +// 提示用户输入 +function prompt(rl: readline.Interface, question: string): Promise { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer.trim()); + }); + }); +} + +async function initTenant() { + const rl = createReadlineInterface(); + + try { + console.log('🚀 开始创建普通租户...\n'); + + // 获取租户信息 + const tenantName = await prompt(rl, '请输入租户名称: '); + if (!tenantName) { + console.error('❌ 错误: 租户名称不能为空'); + process.exit(1); + } + + const tenantCode = await prompt(rl, '请输入租户编码(英文): '); + if (!tenantCode) { + console.error('❌ 错误: 租户编码不能为空'); + process.exit(1); + } + + // 检查租户编码是否已存在 + const existingTenant = await prisma.tenant.findFirst({ + where: { code: tenantCode } + }); + + if (existingTenant) { + console.error(`❌ 错误: 租户编码 "${tenantCode}" 已存在`); + process.exit(1); + } + + rl.close(); + + // 1. 创建租户 + console.log('\n🏢 步骤 1: 创建租户...'); + const tenant = await prisma.tenant.create({ + data: { + name: tenantName, + code: tenantCode, + isSuper: 0, + validState: 1, + } + }); + console.log(` ✓ 创建租户: ${tenant.name} (${tenant.code})`); + + const tenantId = tenant.id; + + // 2. 创建权限 + console.log('\n📝 步骤 2: 创建基础权限...'); + const createdPermissions: { [code: string]: number } = {}; + + for (const perm of allPermissions) { + const permission = await prisma.permission.create({ + data: { ...perm, tenantId, validState: 1 } + }); + createdPermissions[perm.code] = permission.id; + } + console.log(` ✓ 共创建 ${Object.keys(createdPermissions).length} 个权限`); + + // 3. 创建角色并分配权限 + console.log('\n👥 步骤 3: 创建角色并分配权限...'); + const createdRoles: any[] = []; + + for (const roleConfig of normalTenantRoles) { + // 创建角色 + const role = await prisma.role.create({ + data: { + tenantId, + name: roleConfig.name, + code: roleConfig.code, + description: roleConfig.description, + validState: 1, + } + }); + + // 分配权限给角色 + let permCount = 0; + for (const permCode of roleConfig.permissions) { + const permissionId = createdPermissions[permCode]; + if (permissionId) { + await prisma.rolePermission.create({ + data: { + roleId: role.id, + permissionId, + } + }); + permCount++; + } + } + + createdRoles.push({ ...role, permCount }); + console.log(` ✓ 创建角色: ${role.name} (${role.code}) - ${permCount} 个权限`); + } + + // 4. 创建 admin 用户 + console.log('\n👤 步骤 4: 创建 admin 用户...'); + const password = `admin@${tenant.code}`; + const hashedPassword = await bcrypt.hash(password, 10); + + const adminUser = await prisma.user.create({ + data: { + tenantId, + username: 'admin', + password: hashedPassword, + nickname: '管理员', + validState: 1, + } + }); + console.log(` ✓ 创建用户: ${adminUser.username}`); + + // 5. 给用户分配 school_admin 角色 + console.log('\n🔗 步骤 5: 分配角色给用户...'); + const schoolAdminRole = createdRoles.find(r => r.code === 'school_admin'); + if (schoolAdminRole) { + await prisma.userRole.create({ + data: { + userId: adminUser.id, + roleId: schoolAdminRole.id, + } + }); + console.log(` ✓ 分配角色: ${schoolAdminRole.name}`); + } + + // 6. 分配菜单给租户 + console.log('\n📋 步骤 6: 分配菜单给租户...'); + + // 普通租户可见的菜单 + const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '赛事活动', '作业管理', '系统管理']; + const NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理']; + const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['我的报名', '我的作品']; + + const allMenus = await prisma.menu.findMany({ + orderBy: [{ sort: 'asc' }, { id: 'asc' }], + }); + + const normalTenantMenuIds = new Set(); + for (const menu of allMenus) { + // 顶级菜单 + if (!menu.parentId && NORMAL_TENANT_MENUS.includes(menu.name)) { + normalTenantMenuIds.add(menu.id); + } + // 子菜单 + if (menu.parentId) { + const parentMenu = allMenus.find(m => m.id === menu.parentId); + if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) { + // 系统管理下排除部分子菜单 + if (parentMenu.name === '系统管理' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) { + continue; + } + // 赛事活动下排除部分子菜单(只保留活动列表) + if (parentMenu.name === '赛事活动' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) { + continue; + } + normalTenantMenuIds.add(menu.id); + } + } + } + + let menuCount = 0; + for (const menuId of normalTenantMenuIds) { + await prisma.tenantMenu.create({ + data: { + tenantId: tenant.id, + menuId: menuId, + } + }); + menuCount++; + } + console.log(` ✓ 分配 ${menuCount} 个菜单`); + + // 7. 输出结果 + console.log('\n' + '='.repeat(50)); + console.log('🎉 普通租户创建完成!'); + console.log('='.repeat(50)); + console.log(` 租户名称: ${tenant.name}`); + console.log(` 租户编码: ${tenant.code}`); + console.log(` 用户名: admin`); + console.log(` 密码: ${password}`); + console.log(` 权限数量: ${Object.keys(createdPermissions).length}`); + console.log(` 菜单数量: ${menuCount}`); + console.log('\n 📊 角色列表:'); + for (const role of createdRoles) { + console.log(` - ${role.name} (${role.code}): ${role.permCount} 个权限`); + } + console.log('='.repeat(50)); + + } catch (error) { + console.error('❌ 创建失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行初始化 +initTenant() + .then(() => { + console.log('\n✅ 租户创建脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 租户创建脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/src/contests/contests/dto/create-contest.dto.ts b/backend/src/contests/contests/dto/create-contest.dto.ts index 148c096..7896eea 100644 --- a/backend/src/contests/contests/dto/create-contest.dto.ts +++ b/backend/src/contests/contests/dto/create-contest.dto.ts @@ -31,6 +31,11 @@ export enum WorkType { OTHER = 'other', } +export enum RegisterState { + OPEN = 'open', + CLOSED = 'closed', +} + export class CreateContestDto { @IsString() contestName: string; @@ -96,6 +101,10 @@ export class CreateContestDto { @IsDateString() registerEndTime: string; + @IsEnum(RegisterState) + @IsOptional() + registerState?: RegisterState; + @IsBoolean() @IsOptional() requireAudit?: boolean; diff --git a/backend/src/contests/registrations/registrations.service.ts b/backend/src/contests/registrations/registrations.service.ts index dd36b30..bcd9ee8 100644 --- a/backend/src/contests/registrations/registrations.service.ts +++ b/backend/src/contests/registrations/registrations.service.ts @@ -276,7 +276,19 @@ export class RegistrationsService { const where: any = {}; + // 检查当前租户是否为超级租户 + let isSuper = false; if (tenantId) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { isSuper: true }, + }); + isSuper = tenant?.isSuper === 1; + } + + // 超管可以看到所有报名记录(不区分租户) + // 普通租户只能看到自己租户的报名记录 + if (tenantId && !isSuper) { where.tenantId = tenantId; } @@ -375,9 +387,20 @@ export class RegistrationsService { } async findOne(id: number, tenantId?: number) { - const where: any = { id }; - + // 检查当前租户是否为超级租户 + let isSuper = false; if (tenantId) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { isSuper: true }, + }); + isSuper = tenant?.isSuper === 1; + } + + // 构建查询条件 + const where: any = { id }; + // 超管可以查看所有报名记录,普通租户只能查看自己租户的报名 + if (tenantId && !isSuper) { where.tenantId = tenantId; } diff --git a/backend/src/contests/works/dto/query-guided-work.dto.ts b/backend/src/contests/works/dto/query-guided-work.dto.ts new file mode 100644 index 0000000..db788df --- /dev/null +++ b/backend/src/contests/works/dto/query-guided-work.dto.ts @@ -0,0 +1,34 @@ +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryGuidedWorkDto { + @IsInt() + @Min(1) + @Type(() => Number) + @IsOptional() + page?: number = 1; + + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + @IsOptional() + pageSize?: number = 10; + + @IsInt() + @Type(() => Number) + @IsOptional() + contestId?: number; + + @IsString() + @IsOptional() + workNo?: string; + + @IsString() + @IsOptional() + playerName?: string; + + @IsString() + @IsOptional() + accountNo?: string; +} diff --git a/backend/src/contests/works/works.controller.ts b/backend/src/contests/works/works.controller.ts index cf95de8..bcf64d2 100644 --- a/backend/src/contests/works/works.controller.ts +++ b/backend/src/contests/works/works.controller.ts @@ -13,6 +13,7 @@ import { import { WorksService } from './works.service'; import { SubmitWorkDto } from './dto/submit-work.dto'; import { QueryWorkDto } from './dto/query-work.dto'; +import { QueryGuidedWorkDto } from './dto/query-guided-work.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../../auth/decorators/require-permission.decorator'; @@ -42,6 +43,23 @@ export class WorksController { return this.worksService.findAll(queryDto, tenantId); } + /** + * 获取教师指导的作品列表 + */ + @Get('guided') + @RequirePermission('activity:read') + findGuidedWorks( + @Query() queryDto: QueryGuidedWorkDto, + @Request() req, + ) { + const tenantId = req.tenantId || req.user?.tenantId; + const teacherUserId = req.user?.userId; + if (!teacherUserId) { + throw new Error('无法确定教师信息'); + } + return this.worksService.findGuidedWorks(queryDto, tenantId, teacherUserId); + } + @Get(':id') @RequirePermission('work:read') findOne(@Param('id', ParseIntPipe) id: number, @Request() req) { diff --git a/backend/src/contests/works/works.service.ts b/backend/src/contests/works/works.service.ts index ea52258..573133d 100644 --- a/backend/src/contests/works/works.service.ts +++ b/backend/src/contests/works/works.service.ts @@ -106,7 +106,7 @@ export class WorksService { workNo, title: submitWorkDto.title, description: submitWorkDto.description, - files: submitWorkDto.files ? JSON.stringify(submitWorkDto.files) : null, + files: submitWorkDto.files || null, version: existingWorks.length > 0 ? existingWorks[0].version + 1 : 1, isLatest: true, status: 'submitted', @@ -115,9 +115,7 @@ export class WorksService { submitterAccountNo: submitter?.username, submitSource: 'student', // 可以根据实际情况判断 previewUrl: submitWorkDto.previewUrl, - aiModelMeta: submitWorkDto.aiModelMeta - ? JSON.stringify(submitWorkDto.aiModelMeta) - : null, + aiModelMeta: submitWorkDto.aiModelMeta || null, creator: submitterUserId, }; @@ -177,12 +175,23 @@ export class WorksService { } = queryDto; const skip = (page - 1) * pageSize; + // 检查是否为超级租户 + let isSuper = false; + if (tenantId) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { isSuper: true }, + }); + isSuper = tenant?.isSuper === 1; + } + const where: any = { validState: 1, isLatest: true, // 默认只查询最新版本 }; - if (tenantId) { + // 超级租户可以看到所有作品,普通租户只能看到自己租户的作品 + if (tenantId && !isSuper) { where.tenantId = tenantId; } @@ -330,12 +339,23 @@ export class WorksService { } async findOne(id: number, tenantId?: number) { + // 检查是否为超级租户 + let isSuper = false; + if (tenantId) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { isSuper: true }, + }); + isSuper = tenant?.isSuper === 1; + } + const where: any = { id, validState: 1, }; - if (tenantId) { + // 超级租户可以查看所有作品 + if (tenantId && !isSuper) { where.tenantId = tenantId; } @@ -449,4 +469,159 @@ export class WorksService { }, }); } + + /** + * 获取教师指导的作品列表 + * @param queryDto 查询参数 + * @param tenantId 租户ID + * @param teacherUserId 教师用户ID + */ + async findGuidedWorks( + queryDto: { + page?: number; + pageSize?: number; + contestId?: number; + workNo?: string; + playerName?: string; + accountNo?: string; + }, + tenantId: number, + teacherUserId: number, + ) { + const { + page = 1, + pageSize = 10, + contestId, + workNo, + playerName, + accountNo, + } = queryDto; + const skip = (page - 1) * pageSize; + + // 首先获取该教师指导的所有报名记录ID + const teacherRegistrations = await this.prisma.contestRegistrationTeacher.findMany({ + where: { + userId: teacherUserId, + ...(tenantId && { tenantId }), + }, + select: { + registrationId: true, + }, + }); + + const registrationIds = teacherRegistrations.map((r) => r.registrationId); + + if (registrationIds.length === 0) { + return { + list: [], + total: 0, + page, + pageSize, + }; + } + + // 构建查询条件 + const where: any = { + validState: 1, + isLatest: true, + registrationId: { in: registrationIds }, + }; + + if (contestId) { + where.contestId = Number(contestId); + } + + if (workNo) { + where.workNo = { + contains: workNo, + }; + } + + // 处理选手姓名和报名账号的查询 + if (playerName || accountNo) { + const userConditions: any = {}; + if (playerName) { + userConditions.nickname = { contains: playerName }; + } + if (accountNo) { + userConditions.username = { contains: accountNo }; + } + where.registration = { + user: userConditions, + }; + } + + const [list, total] = await Promise.all([ + this.prisma.contestWork.findMany({ + where, + skip, + take: pageSize, + orderBy: { + submitTime: 'desc', + }, + include: { + contest: { + select: { + id: true, + contestName: true, + reviewRuleId: true, + reviewRule: { + select: { + judgeCount: true, + }, + }, + }, + }, + registration: { + include: { + user: { + select: { + id: true, + username: true, + nickname: true, + }, + }, + }, + }, + scores: { + where: { validState: 1 }, + select: { + id: true, + totalScore: true, + }, + }, + _count: { + select: { + scores: true, + assignments: true, + }, + }, + }, + }), + this.prisma.contestWork.count({ where }), + ]); + + // 计算评审进度 + const enrichedList = list.map((work) => { + const reviewedCount = work._count?.scores || 0; + const totalJudgesCount = + work.contest?.reviewRule?.judgeCount || work._count?.assignments || 0; + + return { + ...work, + reviewedCount, + totalJudgesCount, + reviewProgress: totalJudgesCount > 0 + ? `${reviewedCount}/${totalJudgesCount}` + : '未分配', + }; + }); + + return { + list: enrichedList, + total, + page, + pageSize, + }; + } } diff --git a/backend/src/homework/homeworks/homeworks.controller.ts b/backend/src/homework/homeworks/homeworks.controller.ts index 22d757c..510c793 100644 --- a/backend/src/homework/homeworks/homeworks.controller.ts +++ b/backend/src/homework/homeworks/homeworks.controller.ts @@ -28,7 +28,7 @@ export class HomeworksController { * 分页查询作业列表 */ @Get() - @RequirePermission('homework:read', 'homework:student:read') + @RequirePermission('homework:read') findAll( @Query() queryDto: QueryHomeworkDto, @CurrentTenantId() tenantId?: number, @@ -36,6 +36,21 @@ export class HomeworksController { return this.homeworksService.findAll(queryDto, tenantId); } + /** + * 获取我的作业列表(学生端) + * 只返回已发布且对当前学生可见的作业,包含提交状态 + */ + @Get('my') + @RequirePermission('homework-submission:create', 'homework-submission:read') + findMyHomeworks( + @Query() queryDto: QueryHomeworkDto, + @CurrentTenantId() tenantId: number, + @Request() req, + ) { + const userId = req?.user?.userId; + return this.homeworksService.findMyHomeworks(queryDto, tenantId, userId); + } + /** * 创建作业 */ diff --git a/backend/src/homework/homeworks/homeworks.service.ts b/backend/src/homework/homeworks/homeworks.service.ts index ee4c57a..ae2c751 100644 --- a/backend/src/homework/homeworks/homeworks.service.ts +++ b/backend/src/homework/homeworks/homeworks.service.ts @@ -293,8 +293,156 @@ export class HomeworksService { this.prisma.homework.count({ where }), ]); + // 解析 publishScope 并获取班级名称 + const list = await Promise.all( + data.map(async (homework) => { + let publishScopeNames: string[] = []; + if (homework.publishScope) { + try { + const classIds = typeof homework.publishScope === 'string' + ? JSON.parse(homework.publishScope) + : homework.publishScope; + + if (Array.isArray(classIds) && classIds.length > 0) { + const classes = await this.prisma.class.findMany({ + where: { + id: { in: classIds }, + validState: 1, + }, + select: { + id: true, + name: true, + }, + }); + publishScopeNames = classes.map((c) => c.name); + } + } catch (e) { + // 解析失败,保持空数组 + } + } + return { + ...homework, + publishScopeNames, + }; + }), + ); + return { - list: data, + list, + total, + page, + pageSize, + }; + } + + /** + * 获取我的作业列表(学生端) + * 只返回已发布且对当前学生可见的作业,包含提交状态 + */ + async findMyHomeworks(queryDto: QueryHomeworkDto, tenantId: number, userId: number) { + const { page = 1, pageSize = 10, name } = queryDto; + const skip = (page - 1) * pageSize; + + // 获取学生信息,包括班级 + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + student: { + select: { + id: true, + classId: true, + }, + }, + }, + }); + + const studentClassId = user?.student?.classId; + + // 构建查询条件 + const where: any = { + tenantId, + validState: 1, + status: 'published', + }; + + if (name) { + where.name = { + contains: name, + }; + } + + // 查询所有已发布的作业 + const allHomeworks = await this.prisma.homework.findMany({ + where, + orderBy: { + createTime: 'desc', + }, + include: { + reviewRule: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + // 过滤出对当前学生可见的作业(根据 publishScope) + const visibleHomeworks = allHomeworks.filter((homework) => { + if (!homework.publishScope) { + // 没有设置公开范围,不可见 + return false; + } + + try { + const classIds = typeof homework.publishScope === 'string' + ? JSON.parse(homework.publishScope) + : homework.publishScope; + + if (!Array.isArray(classIds) || classIds.length === 0) { + return false; + } + + // 检查学生班级是否在公开范围内 + return studentClassId && classIds.includes(studentClassId); + } catch { + return false; + } + }); + + const total = visibleHomeworks.length; + const pagedHomeworks = visibleHomeworks.slice(skip, skip + pageSize); + + // 获取学生对这些作业的提交记录 + const homeworkIds = pagedHomeworks.map((h) => h.id); + const submissions = await this.prisma.homeworkSubmission.findMany({ + where: { + homeworkId: { in: homeworkIds }, + studentId: userId, + validState: 1, + }, + select: { + id: true, + homeworkId: true, + workName: true, + submitTime: true, + totalScore: true, + }, + }); + + // 构建提交记录映射 + const submissionMap = new Map( + submissions.map((s) => [s.homeworkId, s]), + ); + + // 组装返回数据 + const list = pagedHomeworks.map((homework) => ({ + ...homework, + submission: submissionMap.get(homework.id) || null, + })); + + return { + list, total, page, pageSize, diff --git a/backend/tmpclaude-0d23-cwd b/backend/tmpclaude-0d23-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-0d23-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/backend/tmpclaude-16b9-cwd b/backend/tmpclaude-16b9-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-16b9-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/backend/tmpclaude-3238-cwd b/backend/tmpclaude-3238-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-3238-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/backend/tmpclaude-9916-cwd b/backend/tmpclaude-9916-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-9916-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/backend/tmpclaude-bac0-cwd b/backend/tmpclaude-bac0-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-bac0-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/backend/tmpclaude-cd6c-cwd b/backend/tmpclaude-cd6c-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-cd6c-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/backend/tmpclaude-dcee-cwd b/backend/tmpclaude-dcee-cwd deleted file mode 100644 index 283c91e..0000000 --- a/backend/tmpclaude-dcee-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/82788/Desktop/work/competition-management-system/backend diff --git a/frontend/src/api/contests.ts b/frontend/src/api/contests.ts index 371ba82..9b6ffc7 100644 --- a/frontend/src/api/contests.ts +++ b/frontend/src/api/contests.ts @@ -844,6 +844,19 @@ export const teamsApi = { }, }; +// 教师指导作品查询参数 +export interface QueryGuidedWorkParams extends PaginationParams { + contestId?: number; + workNo?: string; + playerName?: string; + accountNo?: string; +} + +// 教师指导的作品(含评审进度) +export interface GuidedWork extends ContestWork { + reviewProgress: string; +} + // 作品管理 export const worksApi = { // 获取作品列表 @@ -857,6 +870,17 @@ export const worksApi = { return response; }, + // 获取教师指导的作品列表 + getGuidedWorks: async ( + params: QueryGuidedWorkParams + ): Promise> => { + const response = await request.get>( + "/contests/works/guided", + { params } + ); + return response; + }, + // 获取作品详情 getDetail: async (id: number): Promise => { const response = await request.get( diff --git a/frontend/src/api/homework.ts b/frontend/src/api/homework.ts index 94d7020..cdb5a30 100644 --- a/frontend/src/api/homework.ts +++ b/frontend/src/api/homework.ts @@ -13,6 +13,7 @@ export interface Homework { submitEndTime: string; attachments?: HomeworkAttachment[]; publishScope?: number[]; + publishScopeNames?: string[]; reviewRuleId?: number; reviewRule?: HomeworkReviewRule; creator?: number; @@ -23,6 +24,13 @@ export interface Homework { _count?: { submissions: number; }; + // 学生端:我的提交记录 + submission?: { + id: number; + workName: string; + submitTime: string; + totalScore?: number; + } | null; } export interface HomeworkAttachment { @@ -185,7 +193,7 @@ export interface ClassTreeNode { // 作业管理 export const homeworksApi = { - // 获取作业列表 + // 获取作业列表(教师端) getList: async ( params: QueryHomeworkParams ): Promise> => { @@ -196,6 +204,17 @@ export const homeworksApi = { return response; }, + // 获取我的作业列表(学生端) + getMyList: async ( + params: QueryHomeworkParams + ): Promise> => { + const response = await request.get>( + "/homework/homeworks/my", + { params } + ); + return response; + }, + // 获取作业详情 getDetail: async (id: number): Promise => { const response = await request.get( diff --git a/frontend/src/components/ModelViewer.vue b/frontend/src/components/ModelViewer.vue index 43f5b46..9d407f8 100644 --- a/frontend/src/components/ModelViewer.vue +++ b/frontend/src/components/ModelViewer.vue @@ -1,10 +1,10 @@ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d1f8d51..573cce0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -54,7 +54,7 @@ const baseRoutes: RouteRecordRaw[] = [ meta: { title: "赛事详情", requiresAuth: true, - permissions: ["contest:read", "contest:activity:read"], + permissions: ["contest:read", "activity:read"], }, }, // 编辑比赛路由(不需要在菜单中显示) @@ -154,6 +154,17 @@ const baseRoutes: RouteRecordRaw[] = [ permissions: ["homework:read", "homework:student:read"], }, }, + // 教师我的指导路由 + { + path: "student-activities/guidance", + name: "TeacherGuidance", + component: () => import("@/views/contests/Guidance.vue"), + meta: { + title: "我的指导", + requiresAuth: true, + permissions: ["activity:read"], + }, + }, // 动态路由将在这里添加 ], }, diff --git a/frontend/src/views/contests/Detail.vue b/frontend/src/views/contests/Detail.vue index 19cfa0a..6249aad 100644 --- a/frontend/src/views/contests/Detail.vue +++ b/frontend/src/views/contests/Detail.vue @@ -75,7 +75,7 @@ 立即报名 (null) +// 检查是否有查看报名的权限 +const canViewRegistration = computed(() => { + const permissions = authStore.user?.permissions || [] + return permissions.includes('registration:read') || permissions.includes('registration:create') +}) + const contestId = Number(route.params.id) const resultsPagination = ref({ diff --git a/frontend/src/views/contests/Guidance.vue b/frontend/src/views/contests/Guidance.vue new file mode 100644 index 0000000..3e3e16d --- /dev/null +++ b/frontend/src/views/contests/Guidance.vue @@ -0,0 +1,789 @@ + + + + + diff --git a/frontend/src/views/contests/components/ViewWorkDrawer.vue b/frontend/src/views/contests/components/ViewWorkDrawer.vue index d6830e2..9558c7f 100644 --- a/frontend/src/views/contests/components/ViewWorkDrawer.vue +++ b/frontend/src/views/contests/components/ViewWorkDrawer.vue @@ -3,7 +3,7 @@ v-model:open="visible" title="参赛作品" placement="right" - width="500px" + width="850px" :footer-style="{ textAlign: 'right', padding: '16px 24px' }" @close="handleCancel" > @@ -40,7 +40,6 @@
@@ -61,7 +60,6 @@ type="primary" size="small" class="preview-btn" - :class="{ 'btn-visible': isHovering }" @click.stop="handlePreview3DModel(workFile)" > @@ -72,21 +70,6 @@ {{ getFileName(workFile) }}
- -
- - - 预览图片 - -
关闭 +