修改代码

This commit is contained in:
zhangxiaohua 2026-01-12 20:04:11 +08:00
parent aecd72f9ee
commit f2b9918408
34 changed files with 2565 additions and 689 deletions

1
.gitignore vendored
View File

@ -44,3 +44,4 @@ coverage/
# Prisma
backend/prisma/migrations/
tmpclaude-*

View File

@ -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"
}
]
},

View File

@ -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",

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

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

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

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

View File

@ -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) => {

View File

@ -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;

View File

@ -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',
// 学校管理

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

View File

@ -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;

View File

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

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

View File

@ -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) {

View File

@ -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,
};
}
}

View File

@ -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);
}
/**
*
*/

View File

@ -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,

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -1 +0,0 @@
/c/Users/82788/Desktop/work/competition-management-system/backend

View File

@ -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>(

View File

@ -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>(

View File

@ -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>

View File

@ -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"],
},
},
// 动态路由将在这里添加
],
},

View File

@ -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({

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

View File

@ -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 {

View File

@ -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: "提交时间",

View File

@ -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>