修改代码
This commit is contained in:
parent
aecd72f9ee
commit
f2b9918408
1
.gitignore
vendored
1
.gitignore
vendored
@ -44,3 +44,4 @@ coverage/
|
||||
# Prisma
|
||||
backend/prisma/migrations/
|
||||
|
||||
tmpclaude-*
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
60
backend/scripts/check-permissions.js
Normal file
60
backend/scripts/check-permissions.js
Normal file
@ -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();
|
||||
});
|
||||
64
backend/scripts/check-registrations.js
Normal file
64
backend/scripts/check-registrations.js
Normal file
@ -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();
|
||||
});
|
||||
52
backend/scripts/check-student-permissions.js
Normal file
52
backend/scripts/check-student-permissions.js
Normal file
@ -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();
|
||||
});
|
||||
22
backend/scripts/check-users.js
Normal file
22
backend/scripts/check-users.js
Normal file
@ -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();
|
||||
});
|
||||
@ -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<string>();
|
||||
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) => {
|
||||
|
||||
@ -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<number>();
|
||||
for (const menu of allMenus) {
|
||||
// 顶级菜单
|
||||
@ -195,13 +204,37 @@ async function initMenus() {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取普通租户菜单ID(工作台、学校管理、赛事活动、作业管理、部分系统管理)
|
||||
const normalTenantMenuIds = new Set<number>();
|
||||
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;
|
||||
|
||||
@ -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',
|
||||
// 学校管理
|
||||
|
||||
429
backend/scripts/init-tenant.ts
Normal file
429
backend/scripts/init-tenant.ts
Normal file
@ -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<string> {
|
||||
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<number>();
|
||||
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);
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
34
backend/src/contests/works/dto/query-guided-work.dto.ts
Normal file
34
backend/src/contests/works/dto/query-guided-work.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建作业
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -1 +0,0 @@
|
||||
/c/Users/82788/Desktop/work/competition-management-system/backend
|
||||
@ -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<PaginationResponse<GuidedWork>> => {
|
||||
const response = await request.get<any, PaginationResponse<GuidedWork>>(
|
||||
"/contests/works/guided",
|
||||
{ params }
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
// 获取作品详情
|
||||
getDetail: async (id: number): Promise<ContestWork> => {
|
||||
const response = await request.get<any, ContestWork>(
|
||||
|
||||
@ -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<PaginationResponse<Homework>> => {
|
||||
@ -196,6 +204,17 @@ export const homeworksApi = {
|
||||
return response;
|
||||
},
|
||||
|
||||
// 获取我的作业列表(学生端)
|
||||
getMyList: async (
|
||||
params: QueryHomeworkParams
|
||||
): Promise<PaginationResponse<Homework>> => {
|
||||
const response = await request.get<any, PaginationResponse<Homework>>(
|
||||
"/homework/homeworks/my",
|
||||
{ params }
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
// 获取作业详情
|
||||
getDetail: async (id: number): Promise<Homework> => {
|
||||
const response = await request.get<any, Homework>(
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<!-- 这个组件现在作为跳转器使用,实际查看器在 /model-viewer 页面 -->
|
||||
<!-- 这个组件作为跳转器使用,实际查看器在 /model-viewer 页面 -->
|
||||
<span></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, nextTick } from "vue"
|
||||
import { watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
interface Props {
|
||||
@ -22,23 +22,22 @@ const router = useRouter()
|
||||
|
||||
// 监听打开状态,跳转到全屏页面
|
||||
watch(
|
||||
[() => props.open, () => props.modelUrl],
|
||||
([newOpen, newUrl]) => {
|
||||
console.log("ModelViewer watch triggered:", { newOpen, newUrl })
|
||||
if (newOpen && newUrl) {
|
||||
// 延迟一下再跳转,确保状态已更新
|
||||
nextTick(() => {
|
||||
console.log("正在跳转到模型查看页面:", newUrl)
|
||||
// 先关闭
|
||||
emit("update:open", false)
|
||||
// 再跳转
|
||||
router.push({
|
||||
path: "/model-viewer",
|
||||
query: { url: newUrl }
|
||||
})
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
console.log("ModelViewer watch triggered:", { open: newOpen, url: props.modelUrl })
|
||||
if (newOpen && props.modelUrl) {
|
||||
console.log("正在跳转到模型查看页面:", props.modelUrl)
|
||||
// 先关闭状态
|
||||
emit("update:open", false)
|
||||
// 跳转到模型查看页面
|
||||
router.push({
|
||||
path: "/model-viewer",
|
||||
query: { url: props.modelUrl }
|
||||
})
|
||||
} else if (newOpen && !props.modelUrl) {
|
||||
console.error("模型URL为空,无法跳转")
|
||||
emit("update:open", false)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
},
|
||||
// 动态路由将在这里添加
|
||||
],
|
||||
},
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
立即报名
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="hasRegistered"
|
||||
v-else-if="hasRegistered && canViewRegistration"
|
||||
type="default"
|
||||
size="large"
|
||||
class="register-button"
|
||||
@ -376,6 +376,12 @@ const activeTab = ref("info")
|
||||
const hasRegistered = ref(false)
|
||||
const myRegistration = ref<any>(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({
|
||||
|
||||
789
frontend/src/views/contests/Guidance.vue
Normal file
789
frontend/src/views/contests/Guidance.vue
Normal file
@ -0,0 +1,789 @@
|
||||
<template>
|
||||
<div class="guidance-page">
|
||||
<!-- 顶部返回和标题 -->
|
||||
<a-card class="mb-4">
|
||||
<template #title>
|
||||
<div class="page-header">
|
||||
<a-button type="link" @click="handleBack" class="back-btn">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回
|
||||
</a-button>
|
||||
<span class="page-title">我的指导</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<a-form
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form-item label="选手姓名">
|
||||
<a-input
|
||||
v-model:value="searchParams.playerName"
|
||||
placeholder="请输入选手姓名"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="报名账号">
|
||||
<a-input
|
||||
v-model:value="searchParams.accountNo"
|
||||
placeholder="请输入报名账号"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="作品编号">
|
||||
<a-input
|
||||
v-model:value="searchParams.workNo"
|
||||
placeholder="请输入作品编号"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workNo'">
|
||||
<a class="work-link" @click="handleViewWork(record)">{{
|
||||
record.workNo || "-"
|
||||
}}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'accountNo'">
|
||||
{{ record.registration?.user?.username || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'playerName'">
|
||||
{{ record.registration?.user?.nickname || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'submitTime'">
|
||||
{{ formatDateTime(record.submitTime) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reviewProgress'">
|
||||
<a-tag :color="getProgressColor(record.reviewProgress)">
|
||||
{{ record.reviewProgress }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 作品详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="workModalVisible"
|
||||
placement="right"
|
||||
width="600px"
|
||||
:footer-style="{ textAlign: 'right', padding: '16px 24px' }"
|
||||
@close="workModalVisible = false"
|
||||
>
|
||||
<template #title>
|
||||
<div class="drawer-title">
|
||||
{{ currentWork?.workNo || '-' }} - {{ currentWork?.title || '作品详情' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-spin :spinning="workDetailLoading">
|
||||
<div v-if="currentWork" class="work-detail">
|
||||
<!-- 提交时间 -->
|
||||
<div class="work-section">
|
||||
<div class="section-label">提交时间</div>
|
||||
<div class="section-content">{{ formatDateTime(currentWork.submitTime) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品介绍 -->
|
||||
<div class="work-section">
|
||||
<div class="section-label">作品介绍</div>
|
||||
<div class="section-content description-text">
|
||||
{{ currentWork.description || '暂无介绍' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参赛作品 -->
|
||||
<div class="work-section">
|
||||
<div class="section-label">参赛作品</div>
|
||||
<div class="work-file-container">
|
||||
<div v-if="workFile" class="work-image-item">
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
v-if="isImageFile(workFile)"
|
||||
:src="getFileUrl(workFile)"
|
||||
alt="作品图片"
|
||||
class="work-image"
|
||||
@click="handlePreviewImage(workFile)"
|
||||
/>
|
||||
<div v-else-if="is3DModelFile(workFile)" class="file-placeholder model-file">
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="preview-btn"
|
||||
@click.stop="handlePreview3DModel(workFile)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
预览3D模型
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-else class="file-placeholder">
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||||
<a-button type="link" size="small" @click="handleDownloadFile(workFile)">
|
||||
<DownloadOutlined /> 下载
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-files">暂无作品文件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品信息 -->
|
||||
<div class="work-section">
|
||||
<div class="section-label">作品信息</div>
|
||||
<div class="work-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">作品编号:</span>
|
||||
<span class="info-value">{{ currentWork.workNo || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">所属比赛:</span>
|
||||
<span class="info-value">{{ currentWork.contest?.contestName || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">提交人:</span>
|
||||
<span class="info-value">{{ currentWork.registration?.user?.nickname || currentWork.submitterAccountNo || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">报名账号:</span>
|
||||
<span class="info-value">{{ currentWork.registration?.user?.username || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">评审进度:</span>
|
||||
<a-tag :color="getProgressColor(currentWork.reviewProgress || '未分配')">
|
||||
{{ currentWork.reviewProgress || '未分配' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="currentWork.averageScore !== null && currentWork.averageScore !== undefined" class="info-item">
|
||||
<span class="info-label">当前得分:</span>
|
||||
<span class="score-text">{{ currentWork.averageScore?.toFixed(2) }} 分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传附件 -->
|
||||
<div v-if="workAttachments.length > 0" class="work-section">
|
||||
<div class="section-label">上传附件</div>
|
||||
<div class="attachments-list">
|
||||
<div
|
||||
v-for="(att, index) in workAttachments"
|
||||
:key="index"
|
||||
class="attachment-item"
|
||||
>
|
||||
<div class="attachment-info">
|
||||
<FileOutlined class="attachment-icon" />
|
||||
<span class="attachment-name">{{ att.fileName }}</span>
|
||||
<span v-if="att.size" class="attachment-size">({{ formatFileSize(att.size) }})</span>
|
||||
</div>
|
||||
<a-button type="link" size="small" @click="handleDownloadAttachment(att)">
|
||||
<DownloadOutlined /> 下载
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="workModalVisible = false">关闭</a-button>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<a-image
|
||||
:style="{ display: 'none' }"
|
||||
:preview="{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: (vis: boolean) => (previewVisible = vis),
|
||||
}"
|
||||
:src="previewImageUrl"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
FileOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
worksApi,
|
||||
type GuidedWork,
|
||||
type QueryGuidedWorkParams,
|
||||
type ContestWork,
|
||||
} from "@/api/contests"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const contestId = route.query.contestId
|
||||
? Number(route.query.contestId)
|
||||
: undefined
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<GuidedWork[]>([])
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
})
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive<QueryGuidedWorkParams>({
|
||||
contestId,
|
||||
playerName: "",
|
||||
accountNo: "",
|
||||
workNo: "",
|
||||
})
|
||||
|
||||
// 作品详情抽屉
|
||||
const workModalVisible = ref(false)
|
||||
const workDetailLoading = ref(false)
|
||||
const currentWork = ref<(ContestWork & { reviewProgress?: string; averageScore?: number }) | null>(null)
|
||||
|
||||
// 图片预览
|
||||
const previewVisible = ref(false)
|
||||
const previewImageUrl = ref("")
|
||||
|
||||
// 作品文件(取第一个)
|
||||
const workFile = computed(() => {
|
||||
if (!currentWork.value) return null
|
||||
let files = currentWork.value.files || []
|
||||
|
||||
// 如果 files 是字符串(JSON),需要解析
|
||||
if (typeof files === 'string') {
|
||||
try {
|
||||
files = JSON.parse(files)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) return null
|
||||
|
||||
// 处理可能是对象 {fileUrl: string} 或字符串的情况
|
||||
const firstFile = files[0]
|
||||
return typeof firstFile === 'object' && firstFile?.fileUrl
|
||||
? firstFile.fileUrl
|
||||
: firstFile
|
||||
})
|
||||
|
||||
// 解析附件列表
|
||||
const workAttachments = computed(() => {
|
||||
if (!currentWork.value) return []
|
||||
|
||||
// 优先使用 attachments 数组
|
||||
if (currentWork.value.attachments && currentWork.value.attachments.length > 0) {
|
||||
return currentWork.value.attachments
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (fileUrl: string): boolean => {
|
||||
if (!fileUrl) return false
|
||||
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
|
||||
const lowerUrl = fileUrl.toLowerCase()
|
||||
return imageExtensions.some((ext) => lowerUrl.includes(ext))
|
||||
}
|
||||
|
||||
// 判断是否为3D模型文件
|
||||
const is3DModelFile = (fileUrl: string): boolean => {
|
||||
if (!fileUrl) return false
|
||||
const modelExtensions = [".glb", ".gltf", ".obj", ".fbx", ".3ds", ".dae", ".stl", ".ply"]
|
||||
const lowerUrl = fileUrl.toLowerCase()
|
||||
return modelExtensions.some((ext) => lowerUrl.includes(ext))
|
||||
}
|
||||
|
||||
// 获取文件URL
|
||||
const getFileUrl = (fileUrl: string): string => {
|
||||
if (!fileUrl) return ""
|
||||
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
|
||||
return fileUrl
|
||||
}
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || ""
|
||||
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
const getFileName = (fileUrl: string): string => {
|
||||
if (!fileUrl) return "文件"
|
||||
try {
|
||||
const urlWithoutQuery = fileUrl.split("?")[0].split("#")[0]
|
||||
const parts = urlWithoutQuery.split("/")
|
||||
let fileName = parts[parts.length - 1] || "文件"
|
||||
try {
|
||||
fileName = decodeURIComponent(fileName)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return fileName
|
||||
} catch {
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const handlePreviewImage = (fileUrl: string) => {
|
||||
previewImageUrl.value = getFileUrl(fileUrl)
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const handleDownloadFile = (fileUrl: string) => {
|
||||
const url = getFileUrl(fileUrl)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = getFileName(fileUrl)
|
||||
link.target = "_blank"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 下载附件
|
||||
const handleDownloadAttachment = (attachment: any) => {
|
||||
const url = getFileUrl(attachment.fileUrl)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = attachment.fileName
|
||||
link.target = "_blank"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 预览3D模型
|
||||
const handlePreview3DModel = (fileUrl: string) => {
|
||||
console.log("handlePreview3DModel called with:", fileUrl)
|
||||
if (!fileUrl) {
|
||||
message.error("文件路径无效")
|
||||
return
|
||||
}
|
||||
const fullUrl = getFileUrl(fileUrl)
|
||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||
// 直接在新标签页打开模型查看器
|
||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
||||
window.open(viewerUrl, "_blank")
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 70,
|
||||
align: "center" as const,
|
||||
},
|
||||
{
|
||||
title: "作品编号",
|
||||
key: "workNo",
|
||||
dataIndex: "workNo",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "报名账号",
|
||||
key: "accountNo",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "选手姓名",
|
||||
key: "playerName",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "递交时间",
|
||||
key: "submitTime",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "评审进度",
|
||||
key: "reviewProgress",
|
||||
width: 120,
|
||||
align: "center" as const,
|
||||
},
|
||||
]
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr?: string) => {
|
||||
if (!dateStr) return "-"
|
||||
return dayjs(dateStr).format("YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (size?: string | number) => {
|
||||
if (!size) return ""
|
||||
const bytes = typeof size === "string" ? parseInt(size) : size
|
||||
if (isNaN(bytes)) return ""
|
||||
if (bytes < 1024) return bytes + " B"
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + " MB"
|
||||
}
|
||||
|
||||
// 获取进度颜色
|
||||
const getProgressColor = (progress: string) => {
|
||||
if (progress === "未分配") return "default"
|
||||
const [reviewed, total] = progress.split("/").map(Number)
|
||||
if (reviewed === 0) return "warning"
|
||||
if (reviewed >= total) return "success"
|
||||
return "processing"
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: QueryGuidedWorkParams = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
contestId: searchParams.contestId,
|
||||
}
|
||||
|
||||
if (searchParams.playerName) {
|
||||
params.playerName = searchParams.playerName
|
||||
}
|
||||
if (searchParams.accountNo) {
|
||||
params.accountNo = searchParams.accountNo
|
||||
}
|
||||
if (searchParams.workNo) {
|
||||
params.workNo = searchParams.workNo
|
||||
}
|
||||
|
||||
const response = await worksApi.getGuidedWorks(params)
|
||||
dataSource.value = response.list
|
||||
pagination.total = response.total
|
||||
} catch (error) {
|
||||
message.error("获取作品列表失败")
|
||||
console.error("List request error:", error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看作品详情
|
||||
const handleViewWork = async (record: GuidedWork) => {
|
||||
workModalVisible.value = true
|
||||
workDetailLoading.value = true
|
||||
currentWork.value = null
|
||||
|
||||
try {
|
||||
const detail = await worksApi.getDetail(record.id)
|
||||
currentWork.value = {
|
||||
...detail,
|
||||
reviewProgress: record.reviewProgress,
|
||||
averageScore: record.averageScore,
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("获取作品详情失败")
|
||||
} finally {
|
||||
workDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
searchParams.playerName = ""
|
||||
searchParams.accountNo = ""
|
||||
searchParams.workNo = ""
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 返回
|
||||
const handleBack = () => {
|
||||
router.push(`/${tenantCode}/activities`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guidance-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.work-link {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.work-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 抽屉样式 */
|
||||
.drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.work-detail {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.work-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.work-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.work-file-container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.work-image-item {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.image-wrapper:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.work-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-placeholder.model-file {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.no-files {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.work-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-right: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 16px;
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-right: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -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 @@
|
||||
<div v-if="workFile" class="work-image-item">
|
||||
<div
|
||||
class="image-wrapper"
|
||||
:class="{ 'is-hovering': isHovering }"
|
||||
@mouseenter="handleImageHover(workFile)"
|
||||
@mouseleave="handleImageLeave"
|
||||
>
|
||||
@ -61,7 +60,6 @@
|
||||
type="primary"
|
||||
size="small"
|
||||
class="preview-btn"
|
||||
:class="{ 'btn-visible': isHovering }"
|
||||
@click.stop="handlePreview3DModel(workFile)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
@ -72,21 +70,6 @@
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||||
</div>
|
||||
<!-- 图片预览按钮遮罩 -->
|
||||
<div
|
||||
v-if="isImageFile(workFile) && isHovering"
|
||||
class="preview-overlay"
|
||||
>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="preview-overlay-btn"
|
||||
@click.stop="handlePreviewImage(workFile)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
预览图片
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 图片预览遮罩 -->
|
||||
<div
|
||||
@ -170,12 +153,12 @@
|
||||
<a-button @click="handleCancel">关闭</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
FileOutlined,
|
||||
@ -198,13 +181,11 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const work = ref<ContestWork | null>(null)
|
||||
const previewImage = ref<string | null>(null)
|
||||
const isHovering = ref(false)
|
||||
|
||||
// 监听抽屉打开状态
|
||||
watch(
|
||||
@ -216,7 +197,6 @@ watch(
|
||||
} else {
|
||||
work.value = null
|
||||
previewImage.value = null
|
||||
isHovering.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@ -275,40 +255,28 @@ const fetchUserWork = async () => {
|
||||
// 作品文件(只取第一个)
|
||||
const workFile = computed(() => {
|
||||
if (!work.value) return null
|
||||
|
||||
let files = work.value.files || []
|
||||
|
||||
// 如果 files 是字符串(JSON),尝试解析
|
||||
if (typeof files === "string") {
|
||||
// 如果 files 是字符串(JSON),需要解析
|
||||
if (typeof files === 'string') {
|
||||
try {
|
||||
files = JSON.parse(files)
|
||||
} catch (e) {
|
||||
console.error("解析文件列表失败:", e)
|
||||
files = []
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 files 是数组
|
||||
if (!Array.isArray(files)) {
|
||||
files = []
|
||||
}
|
||||
if (!Array.isArray(files) || files.length === 0) return null
|
||||
|
||||
const file = files.length > 0 ? files[0] : null
|
||||
|
||||
// 调试信息
|
||||
if (file) {
|
||||
console.log("当前文件:", file)
|
||||
console.log("是否为图片:", isImageFile(file))
|
||||
console.log("是否为3D模型:", is3DModelFile(file))
|
||||
}
|
||||
|
||||
return file
|
||||
// 处理可能是对象 {fileUrl: string} 或字符串的情况
|
||||
const firstFile = files[0]
|
||||
return typeof firstFile === 'object' && firstFile?.fileUrl
|
||||
? firstFile.fileUrl
|
||||
: firstFile
|
||||
})
|
||||
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (fileUrl: string | null): boolean => {
|
||||
if (!fileUrl || typeof fileUrl !== "string") return false
|
||||
|
||||
const isImageFile = (fileUrl: string): boolean => {
|
||||
const imageExtensions = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@ -323,9 +291,7 @@ const isImageFile = (fileUrl: string | null): boolean => {
|
||||
}
|
||||
|
||||
// 判断是否为3D模型文件
|
||||
const is3DModelFile = (fileUrl: string | null): boolean => {
|
||||
if (!fileUrl || typeof fileUrl !== "string") return false
|
||||
|
||||
const is3DModelFile = (fileUrl: string): boolean => {
|
||||
const modelExtensions = [
|
||||
".glb",
|
||||
".gltf",
|
||||
@ -403,13 +369,7 @@ const getFileName = (fileUrl: string): string => {
|
||||
}
|
||||
|
||||
// 图片鼠标移入
|
||||
const handleImageHover = (file: string | null) => {
|
||||
if (!file) return
|
||||
|
||||
console.log("鼠标移入,文件:", file)
|
||||
console.log("isHovering 设置为 true")
|
||||
isHovering.value = true
|
||||
|
||||
const handleImageHover = (file: string) => {
|
||||
if (isImageFile(file)) {
|
||||
previewImage.value = file
|
||||
}
|
||||
@ -417,7 +377,6 @@ const handleImageHover = (file: string | null) => {
|
||||
|
||||
// 图片鼠标移出
|
||||
const handleImageLeave = () => {
|
||||
isHovering.value = false
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
@ -494,24 +453,18 @@ const getStatusText = (
|
||||
return textMap[status || "submitted"] || "未知"
|
||||
}
|
||||
|
||||
// 预览3D模型 - 跳转到全屏页面
|
||||
// 预览3D模型
|
||||
const handlePreview3DModel = (fileUrl: string) => {
|
||||
console.log("handlePreview3DModel called with:", fileUrl)
|
||||
if (!fileUrl) {
|
||||
message.error("文件路径无效")
|
||||
return
|
||||
}
|
||||
const fullUrl = getFileUrl(fileUrl)
|
||||
console.log("预览3D模型,文件URL:", fullUrl)
|
||||
|
||||
// 直接使用 location 跳转
|
||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||
// 直接在新标签页打开模型查看器
|
||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
||||
console.log("跳转URL:", viewerUrl)
|
||||
window.location.href = viewerUrl
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const handlePreviewImage = (fileUrl: string) => {
|
||||
previewImage.value = fileUrl
|
||||
window.open(viewerUrl, "_blank")
|
||||
}
|
||||
|
||||
// 取消
|
||||
@ -581,10 +534,8 @@ const handleCancel = () => {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #fafafa;
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&.is-hovering {
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@ -637,54 +588,6 @@ const handleCancel = () => {
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease-in-out;
|
||||
z-index: 10;
|
||||
|
||||
&.btn-visible,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.preview-overlay-btn {
|
||||
animation: scaleIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
|
||||
@ -78,8 +78,11 @@
|
||||
{{ record.status === "published" ? "已发布" : "未发布" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'publishTime'">
|
||||
{{ record.publishTime ? formatDateTime(record.publishTime) : "-" }}
|
||||
<template v-else-if="column.key === 'publishScope'">
|
||||
<span v-if="record.publishScopeNames && record.publishScopeNames.length > 0">
|
||||
{{ record.publishScopeNames.join("、") }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'submitTime'">
|
||||
<div>
|
||||
@ -297,9 +300,9 @@ const columns = [
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "公开时间",
|
||||
key: "publishTime",
|
||||
width: 180,
|
||||
title: "公开范围",
|
||||
key: "publishScope",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "提交时间",
|
||||
|
||||
@ -45,8 +45,8 @@
|
||||
{{ record.name }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getSubmitStatusColor(record)">
|
||||
{{ getSubmitStatusText(record) }}
|
||||
<a-tag :color="record.submission ? 'success' : 'warning'">
|
||||
{{ record.submission ? "已提交" : "待提交" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'submitTime'">
|
||||
@ -62,24 +62,160 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 作业详情侧边弹框 -->
|
||||
<a-drawer
|
||||
v-model:open="detailModalVisible"
|
||||
title="作业详情"
|
||||
placement="right"
|
||||
width="500px"
|
||||
destroy-on-close
|
||||
:footer-style="{ textAlign: 'right' }"
|
||||
>
|
||||
<a-spin :spinning="detailLoading">
|
||||
<!-- 作业信息 -->
|
||||
<div class="section-title">作业信息</div>
|
||||
<a-descriptions :column="1" size="small" bordered class="mb-4">
|
||||
<a-descriptions-item label="作业名称">
|
||||
{{ currentHomework?.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间">
|
||||
{{ formatDateTime(currentHomework?.submitStartTime) }} ~
|
||||
{{ formatDateTime(currentHomework?.submitEndTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="作业描述">
|
||||
<div class="homework-content" v-html="formatContent(currentHomework?.content)"></div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="附件下载" v-if="homeworkAttachments.length > 0">
|
||||
<div class="attachment-list">
|
||||
<a
|
||||
v-for="(att, index) in homeworkAttachments"
|
||||
:key="index"
|
||||
:href="att.fileUrl"
|
||||
target="_blank"
|
||||
class="attachment-item"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
{{ att.fileName }}
|
||||
</a>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 作业提交 -->
|
||||
<div class="section-title">作业提交</div>
|
||||
<a-result
|
||||
v-if="isExpired && !currentSubmission"
|
||||
status="warning"
|
||||
title="提交已截止"
|
||||
sub-title="很抱歉,作业提交时间已过"
|
||||
/>
|
||||
|
||||
<a-form
|
||||
v-else
|
||||
ref="formRef"
|
||||
:model="submitForm"
|
||||
:rules="currentSubmission ? {} : formRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="作品名称" name="workName">
|
||||
<a-input
|
||||
v-model:value="submitForm.workName"
|
||||
placeholder="请输入作品名称"
|
||||
:disabled="!!currentSubmission"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="作品介绍" name="workDescription">
|
||||
<a-textarea
|
||||
v-model:value="submitForm.workDescription"
|
||||
placeholder="请输入作品介绍"
|
||||
:rows="4"
|
||||
:disabled="!!currentSubmission"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="上传作品" name="files">
|
||||
<template v-if="currentSubmission">
|
||||
<!-- 已提交:显示已上传的文件 -->
|
||||
<div class="file-list" v-if="submissionFiles.length > 0">
|
||||
<a
|
||||
v-for="(file, index) in submissionFiles"
|
||||
:key="index"
|
||||
:href="file.fileUrl"
|
||||
target="_blank"
|
||||
class="file-item"
|
||||
>
|
||||
<FileOutlined />
|
||||
{{ file.fileName }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-else class="text-gray">暂无附件</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 未提交:显示上传组件 -->
|
||||
<a-upload
|
||||
v-model:file-list="submitForm.fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customUpload"
|
||||
>
|
||||
<a-button>
|
||||
<template #icon><UploadOutlined /></template>
|
||||
上传附件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<!-- 已提交显示得分 -->
|
||||
<a-form-item v-if="currentSubmission && currentSubmission.totalScore !== null && currentSubmission.totalScore !== undefined" label="得分">
|
||||
<a-tag color="blue" style="font-size: 16px;">
|
||||
{{ currentSubmission.totalScore }}分
|
||||
</a-tag>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button @click="detailModalVisible = false">取消</a-button>
|
||||
<a-button
|
||||
v-if="!currentSubmission && !isExpired"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定提交
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||
import { ref, reactive, computed, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import type { FormInstance, UploadProps } from "ant-design-vue"
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
FileOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useListRequest } from "@/composables/useListRequest"
|
||||
import {
|
||||
homeworksApi,
|
||||
submissionsApi,
|
||||
type Homework,
|
||||
type QueryHomeworkParams,
|
||||
type HomeworkSubmission,
|
||||
type HomeworkAttachment,
|
||||
} from "@/api/homework"
|
||||
import { uploadApi } from "@/api/upload"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
|
||||
// 使用列表请求组合函数 - 只获取已发布的作业
|
||||
const {
|
||||
@ -92,7 +228,7 @@ const {
|
||||
search,
|
||||
handleTableChange,
|
||||
} = useListRequest<Homework, QueryHomeworkParams>({
|
||||
requestFn: (params) => homeworksApi.getList({ ...params, status: 'published' }),
|
||||
requestFn: (params) => homeworksApi.getMyList({ ...params, status: 'published' }),
|
||||
defaultSearchParams: {} as QueryHomeworkParams,
|
||||
defaultPageSize: 10,
|
||||
errorMessage: "获取作业列表失败",
|
||||
@ -113,9 +249,9 @@ const columns = [
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: "提交状态",
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 120,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "提交时间",
|
||||
@ -136,42 +272,10 @@ const formatDateTime = (dateStr?: string) => {
|
||||
return dayjs(dateStr).format("YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
// 获取提交状态颜色
|
||||
const getSubmitStatusColor = (record: Homework) => {
|
||||
const now = dayjs()
|
||||
const endTime = dayjs(record.submitEndTime)
|
||||
|
||||
// 如果已提交
|
||||
if (record.submission) {
|
||||
return "success"
|
||||
}
|
||||
|
||||
// 如果已过期
|
||||
if (now.isAfter(endTime)) {
|
||||
return "error"
|
||||
}
|
||||
|
||||
// 待提交
|
||||
return "warning"
|
||||
}
|
||||
|
||||
// 获取提交状态文本
|
||||
const getSubmitStatusText = (record: Homework) => {
|
||||
const now = dayjs()
|
||||
const endTime = dayjs(record.submitEndTime)
|
||||
|
||||
// 如果已提交
|
||||
if (record.submission) {
|
||||
return "已提交"
|
||||
}
|
||||
|
||||
// 如果已过期
|
||||
if (now.isAfter(endTime)) {
|
||||
return "已过期"
|
||||
}
|
||||
|
||||
// 待提交
|
||||
return "待提交"
|
||||
// 格式化内容(保留换行)
|
||||
const formatContent = (content?: string) => {
|
||||
if (!content) return ""
|
||||
return content.replace(/\n/g, "<br/>")
|
||||
}
|
||||
|
||||
// 搜索
|
||||
@ -184,9 +288,144 @@ const handleReset = () => {
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
// =============== 详情弹框相关 ===============
|
||||
const detailModalVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const currentHomework = ref<Homework | null>(null)
|
||||
const currentSubmission = ref<HomeworkSubmission | null>(null)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 解析作业附件
|
||||
const homeworkAttachments = computed<HomeworkAttachment[]>(() => {
|
||||
if (!currentHomework.value?.attachments) return []
|
||||
if (typeof currentHomework.value.attachments === "string") {
|
||||
try {
|
||||
return JSON.parse(currentHomework.value.attachments)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return currentHomework.value.attachments as HomeworkAttachment[]
|
||||
})
|
||||
|
||||
// 解析提交的文件
|
||||
const submissionFiles = computed<HomeworkAttachment[]>(() => {
|
||||
if (!currentSubmission.value?.files) return []
|
||||
if (typeof currentSubmission.value.files === "string") {
|
||||
try {
|
||||
return JSON.parse(currentSubmission.value.files)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return currentSubmission.value.files as HomeworkAttachment[]
|
||||
})
|
||||
|
||||
// 是否过期
|
||||
const isExpired = computed(() => {
|
||||
if (!currentHomework.value) return false
|
||||
return dayjs().isAfter(dayjs(currentHomework.value.submitEndTime))
|
||||
})
|
||||
|
||||
// 提交表单
|
||||
const submitForm = reactive<{
|
||||
workName: string
|
||||
workDescription: string
|
||||
fileList: any[]
|
||||
}>({
|
||||
workName: "",
|
||||
workDescription: "",
|
||||
fileList: [],
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
workName: [{ required: true, message: "请输入作品名称" }],
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: Homework) => {
|
||||
router.push(`/${tenantCode}/homework/detail/${record.id}`)
|
||||
const handleViewDetail = async (record: Homework) => {
|
||||
// 重置状态
|
||||
currentHomework.value = null
|
||||
currentSubmission.value = null
|
||||
detailLoading.value = true
|
||||
detailModalVisible.value = true
|
||||
|
||||
// 重置表单
|
||||
submitForm.workName = ""
|
||||
submitForm.workDescription = ""
|
||||
submitForm.fileList = []
|
||||
|
||||
// 加载作业详情
|
||||
try {
|
||||
const detail = await homeworksApi.getDetail(record.id)
|
||||
currentHomework.value = detail
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取作业详情失败")
|
||||
detailLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 获取我的提交记录(404 是正常情况,不提示错误)
|
||||
try {
|
||||
const mySubmission = await submissionsApi.getMySubmission(record.id)
|
||||
currentSubmission.value = mySubmission
|
||||
} catch {
|
||||
// 未找到提交记录是正常情况,不提示错误
|
||||
currentSubmission.value = null
|
||||
}
|
||||
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
const beforeUpload: UploadProps["beforeUpload"] = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
const customUpload = async ({ file, onSuccess, onError }: any) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
const result = await uploadApi.upload(formData)
|
||||
file.url = result.url
|
||||
onSuccess(result)
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交作业
|
||||
const handleSubmit = async () => {
|
||||
if (!currentHomework.value) return
|
||||
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
submitting.value = true
|
||||
|
||||
const files = submitForm.fileList.map((file) => ({
|
||||
fileName: file.name,
|
||||
fileUrl: file.url || file.response?.url,
|
||||
size: file.size?.toString(),
|
||||
}))
|
||||
|
||||
await submissionsApi.submit({
|
||||
homeworkId: currentHomework.value.id,
|
||||
workName: submitForm.workName,
|
||||
workDescription: submitForm.workDescription,
|
||||
files,
|
||||
})
|
||||
|
||||
message.success("提交成功")
|
||||
detailModalVisible.value = false
|
||||
// 刷新列表
|
||||
fetchList()
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) return
|
||||
message.error(error?.response?.data?.message || "提交失败")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -198,4 +437,59 @@ onMounted(() => {
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.homework-content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user