修改代码

This commit is contained in:
zhangxiaohua 2026-01-12 16:06:34 +08:00
parent bbdeaac93a
commit aecd72f9ee
63 changed files with 4636 additions and 4103 deletions

View File

@ -6,7 +6,7 @@
"component": "workbench/Index",
"parentId": null,
"sort": 1,
"permission": null
"permission": "workbench:read"
},
{
"name": "学校管理",
@ -14,8 +14,8 @@
"icon": "BankOutlined",
"component": null,
"parentId": null,
"sort": 5,
"permission": null,
"sort": 2,
"permission": "school:read",
"children": [
{
"name": "学校信息",
@ -67,70 +67,113 @@
}
]
},
{
"name": "赛事活动",
"path": "/activities",
"icon": "FlagOutlined",
"component": null,
"parentId": null,
"sort": 3,
"permission": "activity:read",
"children": [
{
"name": "活动列表",
"path": "/activities",
"icon": "UnorderedListOutlined",
"component": "contests/Activities",
"sort": 1,
"permission": "activity:read"
},
{
"name": "我的报名",
"path": "/activities/registrations",
"icon": "UserAddOutlined",
"component": "contests/registrations/Index",
"sort": 2,
"permission": "registration:read"
},
{
"name": "我的作品",
"path": "/activities/works",
"icon": "FileTextOutlined",
"component": "contests/works/Index",
"sort": 3,
"permission": "work:read"
}
]
},
{
"name": "赛事管理",
"path": "/contests",
"icon": "TrophyOutlined",
"component": null,
"parentId": null,
"sort": 6,
"permission": null,
"sort": 4,
"permission": "contest:create",
"children": [
{
"name": "赛事活动",
"path": "/contests/activities",
"icon": "AppstoreOutlined",
"component": "contests/Activities",
"sort": 0,
"permission": "contest:read"
},
{
"name": "赛事列表",
"path": "/contests",
"icon": "UnorderedListOutlined",
"component": "contests/Index",
"sort": 1,
"permission": "contest:read"
"permission": "contest:create"
},
{
"name": "评委管理",
"path": "/contests/judges",
"icon": "SolutionOutlined",
"component": "contests/judges/Index",
"sort": 2,
"permission": "judge:read"
},
{
"name": "报名管理",
"path": "/contests/registrations",
"icon": "UserAddOutlined",
"component": "contests/registrations/Index",
"sort": 2,
"permission": "contest:registration:read"
"sort": 3,
"permission": "registration:approve"
},
{
"name": "作品管理",
"path": "/contests/works",
"icon": "FileTextOutlined",
"component": "contests/works/Index",
"sort": 3,
"permission": "contest:work:read"
"sort": 4,
"permission": "work:read"
},
{
"name": "评审任务",
"path": "/contests/review-tasks",
"icon": "AuditOutlined",
"component": "contests/reviews/Tasks",
"sort": 5,
"permission": "review:read"
},
{
"name": "评审规则",
"path": "/contests/reviews",
"icon": "CheckCircleOutlined",
"component": "contests/reviews/Index",
"sort": 4,
"permission": "contest:review:read"
"sort": 6,
"permission": "review-rule:read"
},
{
"name": "赛果发布",
"path": "/contests/results",
"icon": "TrophyOutlined",
"component": "contests/results/Index",
"sort": 5,
"permission": "contest:result:read"
"sort": 7,
"permission": "contest:create"
},
{
"name": "通知管理",
"path": "/contests/notices",
"icon": "BellOutlined",
"component": "contests/notices/Index",
"sort": 7,
"permission": "contest:notice:read"
"sort": 8,
"permission": "notice:create"
}
]
},
@ -140,11 +183,11 @@
"icon": "FormOutlined",
"component": null,
"parentId": null,
"sort": 7,
"permission": null,
"sort": 5,
"permission": "homework:read",
"children": [
{
"name": "作业管理",
"name": "作业列表",
"path": "/homework",
"icon": "FileTextOutlined",
"component": "homework/Index",
@ -157,7 +200,7 @@
"icon": "EditOutlined",
"component": "homework/Submissions",
"sort": 2,
"permission": "homework:read"
"permission": "homework-submission:read"
},
{
"name": "评审规则",
@ -165,7 +208,7 @@
"icon": "CheckCircleOutlined",
"component": "homework/ReviewRules",
"sort": 3,
"permission": "homework:read"
"permission": "homework-review-rule:read"
}
]
},
@ -176,7 +219,7 @@
"component": null,
"parentId": null,
"sort": 10,
"permission": null,
"permission": "user:read",
"children": [
{
"name": "用户管理",

View File

@ -1,4 +1,11 @@
[
{
"code": "workbench:read",
"resource": "workbench",
"action": "read",
"name": "查看工作台",
"description": "允许查看工作台"
},
{
"code": "user:create",
"resource": "user",
@ -125,6 +132,517 @@
"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": "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": "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: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": "registration:approve",
"resource": "registration",
"action": "approve",
"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": "review:read",
"resource": "review",
"action": "read",
"name": "查看评审任务",
"description": "允许查看待评审作品"
},
{
"code": "review:score",
"resource": "review",
"action": "score",
"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": "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": "允许查看作业评分"
},
{
"code": "dict:create",
"resource": "dict",
@ -196,423 +714,17 @@
"description": "允许删除系统日志"
},
{
"code": "school:create",
"resource": "school",
"action": "create",
"name": "创建学校",
"description": "允许创建学校信息"
},
{
"code": "school:read",
"resource": "school",
"code": "activity:read",
"resource": "activity",
"action": "read",
"name": "查看学校",
"description": "允许查看学校信息"
"name": "查看赛事活动",
"description": "允许查看已发布的赛事活动"
},
{
"code": "school:update",
"resource": "school",
"action": "update",
"name": "更新学校",
"description": "允许更新学校信息"
},
{
"code": "school:delete",
"resource": "school",
"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": "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": "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": "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:team:create",
"resource": "contest:team",
"action": "create",
"name": "创建团队",
"description": "允许创建比赛团队"
},
{
"code": "contest:team:read",
"resource": "contest:team",
"action": "read",
"name": "查看团队",
"description": "允许查看团队列表和详情"
},
{
"code": "contest:team:update",
"resource": "contest:team",
"action": "update",
"name": "更新团队",
"description": "允许更新团队信息"
},
{
"code": "contest:team:delete",
"resource": "contest:team",
"action": "delete",
"name": "删除团队",
"description": "允许删除团队"
},
{
"code": "contest:team:manage",
"resource": "contest:team",
"action": "manage",
"name": "管理团队成员",
"description": "允许管理团队成员"
},
{
"code": "contest:review:create",
"resource": "contest:review",
"action": "create",
"name": "创建评审规则",
"description": "允许创建评审规则"
},
{
"code": "contest:review:read",
"resource": "contest:review",
"action": "read",
"name": "查看评审",
"description": "允许查看评审规则和评审记录"
},
{
"code": "contest:review:update",
"resource": "contest:review",
"action": "update",
"name": "更新评审规则",
"description": "允许更新评审规则"
},
{
"code": "contest:review:delete",
"resource": "contest:review",
"action": "delete",
"name": "删除评审规则",
"description": "允许删除评审规则"
},
{
"code": "contest:review:assign",
"resource": "contest:review",
"action": "assign",
"name": "分配评审任务",
"description": "允许分配评审任务给评委"
},
{
"code": "contest:review:score",
"resource": "contest:review",
"action": "score",
"name": "评审打分",
"description": "允许对作品进行评审打分"
},
{
"code": "contest:judge:create",
"resource": "contest:judge",
"action": "create",
"name": "添加评委",
"description": "允许添加比赛评委"
},
{
"code": "contest:judge:read",
"resource": "contest:judge",
"action": "read",
"name": "查看评委",
"description": "允许查看评委列表"
},
{
"code": "contest:judge:update",
"resource": "contest:judge",
"action": "update",
"name": "更新评委",
"description": "允许更新评委信息"
},
{
"code": "contest:judge:delete",
"resource": "contest:judge",
"action": "delete",
"name": "删除评委",
"description": "允许删除评委"
},
{
"code": "contest:work:create",
"resource": "contest:work",
"action": "create",
"name": "创建作品",
"description": "允许创建参赛作品"
},
{
"code": "contest:work:read",
"resource": "contest:work",
"action": "read",
"name": "查看作品",
"description": "允许查看作品列表和详情"
},
{
"code": "contest:work:update",
"resource": "contest:work",
"action": "update",
"name": "更新作品",
"description": "允许更新作品信息"
},
{
"code": "contest:work:delete",
"resource": "contest:work",
"action": "delete",
"name": "删除作品",
"description": "允许删除作品"
},
{
"code": "contest:work:submit",
"resource": "contest:work",
"action": "submit",
"name": "提交作品",
"description": "允许提交作品"
},
{
"code": "contest:work:review",
"resource": "contest:work",
"action": "review",
"name": "审核作品",
"description": "允许审核作品状态"
},
{
"code": "contest:registration:create",
"resource": "contest:registration",
"action": "create",
"name": "创建报名",
"description": "允许创建报名记录"
},
{
"code": "contest:registration:read",
"resource": "contest:registration",
"action": "read",
"name": "查看报名",
"description": "允许查看报名列表和详情"
},
{
"code": "contest:registration:update",
"resource": "contest:registration",
"action": "update",
"name": "更新报名",
"description": "允许更新报名信息"
},
{
"code": "contest:registration:delete",
"resource": "contest:registration",
"action": "delete",
"name": "删除报名",
"description": "允许删除报名记录"
},
{
"code": "contest:registration:approve",
"resource": "contest:registration",
"action": "approve",
"name": "审核报名",
"description": "允许审核报名(通过/拒绝)"
},
{
"code": "contest:notice:create",
"resource": "contest:notice",
"action": "create",
"name": "创建公告",
"description": "允许创建比赛公告"
},
{
"code": "contest:notice:read",
"resource": "contest:notice",
"action": "read",
"name": "查看公告",
"description": "允许查看公告列表和详情"
},
{
"code": "contest:notice:update",
"resource": "contest:notice",
"action": "update",
"name": "更新公告",
"description": "允许更新公告信息"
},
{
"code": "contest:notice:delete",
"resource": "contest:notice",
"action": "delete",
"name": "删除公告",
"description": "允许删除公告"
},
{
"code": "contest:notice:publish",
"resource": "contest:notice",
"action": "publish",
"name": "发布公告",
"description": "允许发布公告"
"code": "activity:guidance",
"resource": "activity",
"action": "guidance",
"name": "指导学生",
"description": "允许指导学生参赛"
}
]

View File

@ -31,12 +31,17 @@
"init:admin:permissions": "ts-node scripts/init-admin-permissions.ts",
"init:menus": "ts-node scripts/init-menus.ts",
"init:super-tenant": "ts-node scripts/init-super-tenant.ts",
"init:linksea-tenant": "ts-node scripts/init-linksea-tenant.ts",
"init:tenant-admin": "ts-node scripts/init-tenant-admin.ts",
"init:tenant-admin:permissions": "ts-node scripts/init-tenant-admin.ts --permissions-only",
"init:tenant-permissions": "ts-node scripts/init-tenant-permissions.ts",
"init:tenant-menu-permissions": "ts-node scripts/init-tenant-menu-permissions.ts",
"update:password": "ts-node scripts/update-password.ts",
"fix:invalid-datetime": "ts-node scripts/fix-invalid-datetime.ts"
"fix:invalid-datetime": "ts-node scripts/fix-invalid-datetime.ts",
"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"
},
"dependencies": {
"@nestjs/common": "^10.3.3",

View File

@ -57,17 +57,18 @@ model Tenant {
/// 用户表
model User {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
username String /// 用户名(在租户内唯一)
password String /// 密码(加密存储)
nickname String /// 昵称
email String? /// 邮箱(在租户内唯一,可选)
phone String? /// 联系方式/手机号
gender String? /// 性别male-男female-女
avatar String? /// 头像URL
status String @default("enabled") /// 账号状态enabled-启用disabled-停用
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
username String /// 用户名(在租户内唯一)
password String /// 密码(加密存储)
nickname String /// 昵称
email String? /// 邮箱(在租户内唯一,可选)
phone String? /// 联系方式/手机号
gender String? /// 性别male-男female-女
avatar String? /// 头像URL
organization String? /// 所属单位(用于评委等独立用户)
status String @default("enabled") /// 账号状态enabled-启用disabled-停用
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
creator Int? @map("creator") /// 创建人ID
modifier Int? @map("modifier") /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间

View File

@ -1,81 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 添加赛事活动下的赛事列表菜单...\n');
// 查找赛事活动父菜单
const activityParent = await prisma.menu.findFirst({
where: { path: '/student-activities' }
});
if (!activityParent) {
console.log('❌ 未找到赛事活动父菜单');
return;
}
// 检查是否已存在赛事列表子菜单
const existing = await prisma.menu.findFirst({
where: { path: '/student-activities/list' }
});
if (existing) {
console.log('⏭️ 赛事列表菜单已存在');
} else {
// 创建赛事列表子菜单
const listMenu = await prisma.menu.create({
data: {
name: '赛事列表',
path: '/student-activities/list',
component: 'contests/Activities',
permission: 'contest:activity:read',
sort: 0, // 排在最前面
parentId: activityParent.id,
validState: 1,
}
});
console.log('✅ 已创建: 赛事列表');
// 分配给所有租户
const tenants = await prisma.tenant.findMany({ select: { id: true } });
for (const tenant of tenants) {
await prisma.tenantMenu.create({
data: { tenantId: tenant.id, menuId: listMenu.id }
});
}
console.log('✅ 已分配给所有租户');
}
// 更新其他子菜单的排序
await prisma.menu.updateMany({
where: { path: '/student-activities/guidance' },
data: { sort: 1 }
});
await prisma.menu.updateMany({
where: { path: '/student-activities/review' },
data: { sort: 2 }
});
await prisma.menu.updateMany({
where: { path: '/student-activities/comments' },
data: { sort: 3 }
});
// 显示最终结构
console.log('\n📋 赛事活动最终菜单结构:');
const children = await prisma.menu.findMany({
where: { parentId: activityParent.id },
orderBy: { sort: 'asc' }
});
console.log(`- ${activityParent.name} (${activityParent.path})`);
children.forEach(c => {
console.log(` - ${c.name} (${c.path})`);
});
console.log('\n✅ 完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,97 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔍 检查现有 homework 权限...');
// 获取所有租户
const tenants = await prisma.tenant.findMany({
select: { id: true, name: true }
});
console.log(`找到 ${tenants.length} 个租户`);
// 定义作业管理权限
const homeworkPermissions = [
{ code: 'homework:read', name: '查看作业', resource: 'homework', action: 'read' },
{ code: 'homework:create', name: '创建作业', resource: 'homework', action: 'create' },
{ code: 'homework:update', name: '编辑作业', resource: 'homework', action: 'update' },
{ code: 'homework:delete', name: '删除作业', resource: 'homework', action: 'delete' },
];
for (const tenant of tenants) {
console.log(`\n📝 为租户 "${tenant.name}" (ID: ${tenant.id}) 添加权限...`);
for (const perm of homeworkPermissions) {
// 检查权限是否已存在
const existing = await prisma.permission.findFirst({
where: {
tenantId: tenant.id,
code: perm.code,
}
});
if (existing) {
console.log(` ⏭️ ${perm.name} (${perm.code}) 已存在`);
} else {
await prisma.permission.create({
data: {
tenantId: tenant.id,
code: perm.code,
name: perm.name,
resource: perm.resource,
action: perm.action,
description: `作业管理 - ${perm.name}`,
}
});
console.log(`${perm.name} (${perm.code}) 已创建`);
}
}
// 获取管理员角色并分配权限
const adminRole = await prisma.role.findFirst({
where: {
tenantId: tenant.id,
code: 'admin',
}
});
if (adminRole) {
console.log(`\n🔐 为管理员角色分配权限...`);
const allHomeworkPerms = await prisma.permission.findMany({
where: {
tenantId: tenant.id,
resource: 'homework',
}
});
for (const perm of allHomeworkPerms) {
const existing = await prisma.rolePermission.findFirst({
where: {
roleId: adminRole.id,
permissionId: perm.id,
}
});
if (!existing) {
await prisma.rolePermission.create({
data: {
roleId: adminRole.id,
permissionId: perm.id,
}
});
console.log(` ✅ 已分配 ${perm.code} 给管理员角色`);
} else {
console.log(` ⏭️ ${perm.code} 已分配`);
}
}
}
}
console.log('\n✅ 权限配置完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,86 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 添加缺失的赛事管理菜单...\n');
// 查找赛事管理父菜单
const contestMenu = await prisma.menu.findFirst({
where: { name: '赛事管理', parentId: null }
});
if (!contestMenu) {
console.log('❌ 未找到赛事管理菜单');
return;
}
console.log('✅ 找到赛事管理菜单 ID:', contestMenu.id);
// 要添加的菜单
const menusToAdd = [
{
name: '评委管理',
path: '/contests/judges',
component: 'contests/judges/Index',
permission: 'contest:judge:read',
sort: 35, // 在评审规则之后
},
{
name: '评审进度',
path: '/contests/reviews/progress',
component: 'contests/reviews/Progress',
permission: 'contest:review:read',
sort: 36,
},
];
for (const menuData of menusToAdd) {
// 检查是否已存在
const existing = await prisma.menu.findFirst({
where: { path: menuData.path }
});
if (existing) {
console.log(`⏭️ ${menuData.name} 已存在`);
continue;
}
// 创建菜单
const newMenu = await prisma.menu.create({
data: {
...menuData,
parentId: contestMenu.id,
validState: 1,
}
});
console.log(`✅ 已创建: ${menuData.name} (ID: ${newMenu.id})`);
// 分配给所有租户
const tenants = await prisma.tenant.findMany({ select: { id: true } });
for (const tenant of tenants) {
await prisma.tenantMenu.create({
data: { tenantId: tenant.id, menuId: newMenu.id }
});
}
console.log(` 已分配给 ${tenants.length} 个租户`);
}
// 显示最终结构
console.log('\n📋 赛事管理最终菜单结构:');
const children = await prisma.menu.findMany({
where: { parentId: contestMenu.id },
orderBy: { sort: 'asc' }
});
console.log(`- ${contestMenu.name}`);
children.forEach(c => {
console.log(` - ${c.name} | ${c.path}`);
});
console.log('\n✅ 完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,124 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 添加学生作业菜单...\n');
// 获取所有租户
const tenants = await prisma.tenant.findMany({
select: { id: true, name: true }
});
for (const tenant of tenants) {
console.log(`\n📝 处理租户: ${tenant.name} (ID: ${tenant.id})`);
// 1. 创建学生作业权限(如果不存在)
let studentHomeworkPerm = await prisma.permission.findFirst({
where: { tenantId: tenant.id, code: 'homework:student:read' }
});
if (!studentHomeworkPerm) {
studentHomeworkPerm = await prisma.permission.create({
data: {
tenantId: tenant.id,
code: 'homework:student:read',
name: '学生查看作业',
resource: 'homework:student',
action: 'read',
description: '学生查看作业列表',
}
});
console.log(' ✅ 创建权限: homework:student:read');
}
// 2. 分配给学生角色
const studentRole = await prisma.role.findFirst({
where: { tenantId: tenant.id, code: 'student' }
});
if (studentRole) {
const existing = await prisma.rolePermission.findFirst({
where: { roleId: studentRole.id, permissionId: studentHomeworkPerm.id }
});
if (!existing) {
await prisma.rolePermission.create({
data: { roleId: studentRole.id, permissionId: studentHomeworkPerm.id }
});
console.log(' ✅ 已分配给学生角色');
}
}
// 3. 创建学生作业菜单
// 先检查是否已存在
const existingMenu = await prisma.menu.findFirst({
where: { path: '/student-homework' }
});
if (!existingMenu) {
const studentHomeworkMenu = await prisma.menu.create({
data: {
name: '我的作业',
path: '/student-homework',
component: 'homework/StudentList',
icon: 'BookOutlined',
permission: 'homework:student:read',
sort: 15, // 放在作业管理之前
validState: 1,
}
});
console.log(' ✅ 创建菜单: 我的作业');
// 4. 将菜单分配给租户
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: studentHomeworkMenu.id,
}
});
console.log(' ✅ 菜单已分配给租户');
} else {
console.log(' ⏭️ 菜单已存在');
// 确保菜单已分配给租户
const tenantMenu = await prisma.tenantMenu.findFirst({
where: { tenantId: tenant.id, menuId: existingMenu.id }
});
if (!tenantMenu) {
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: existingMenu.id,
}
});
console.log(' ✅ 菜单已分配给租户');
}
}
}
// 5. 移除学生角色的 homework:read 权限(这个权限是给管理员的)
console.log('\n📝 更新权限配置...');
const allStudentRoles = await prisma.role.findMany({
where: { code: 'student' }
});
for (const studentRole of allStudentRoles) {
// 获取 homework:read 权限
const homeworkReadPerm = await prisma.permission.findFirst({
where: { tenantId: studentRole.tenantId, code: 'homework:read' }
});
if (homeworkReadPerm) {
await prisma.rolePermission.deleteMany({
where: { roleId: studentRole.id, permissionId: homeworkReadPerm.id }
});
console.log(` ✅ 移除学生角色(ID:${studentRole.id})的 homework:read 权限`);
}
}
console.log('\n✅ 学生作业菜单配置完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,54 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔍 查询角色和权限...');
// 获取所有角色
const roles = await prisma.role.findMany({
select: { id: true, name: true, code: true, tenantId: true }
});
console.log('角色:', roles);
// 获取 homework 权限
const homeworkPerms = await prisma.permission.findMany({
where: { resource: 'homework' },
select: { id: true, code: true, tenantId: true }
});
console.log('Homework 权限:', homeworkPerms);
// 为每个角色分配所有 homework 权限
for (const role of roles) {
console.log(`\n🔐 为角色 "${role.name}" 分配权限...`);
const permsForTenant = homeworkPerms.filter(p => p.tenantId === role.tenantId);
for (const perm of permsForTenant) {
const existing = await prisma.rolePermission.findFirst({
where: {
roleId: role.id,
permissionId: perm.id,
}
});
if (!existing) {
await prisma.rolePermission.create({
data: {
roleId: role.id,
permissionId: perm.id,
}
});
console.log(` ✅ 已分配 ${perm.code}`);
} else {
console.log(` ⏭️ ${perm.code} 已分配`);
}
}
}
console.log('\n✅ 权限分配完成!请重新登录以刷新权限。');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,46 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 查找赛事管理菜单
const contestMenu = await prisma.menu.findFirst({
where: { name: '赛事管理' }
});
console.log('📋 赛事管理菜单:', contestMenu ? `ID ${contestMenu.id}` : '不存在');
if (contestMenu) {
const children = await prisma.menu.findMany({
where: { parentId: contestMenu.id },
orderBy: { sort: 'asc' }
});
console.log('\n子菜单:');
if (children.length === 0) {
console.log(' (无子菜单)');
}
children.forEach(c => console.log(` - ${c.name} | ${c.path} | ${c.permission}`));
}
// 查找所有赛事相关菜单
console.log('\n📋 所有赛事相关菜单:');
const allContestMenus = await prisma.menu.findMany({
where: {
OR: [
{ path: { contains: 'contest' } },
{ name: { contains: '赛事' } },
{ name: { contains: '赛果' } },
{ name: { contains: '报名' } },
{ name: { contains: '作品' } },
]
},
orderBy: [{ parentId: 'asc' }, { sort: 'asc' }]
});
allContestMenus.forEach(m => {
const type = m.parentId ? ' (子)' : '(父)';
console.log(`${type} ${m.name} | ${m.path} | parentId: ${m.parentId}`);
});
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,47 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const studentRole = await prisma.role.findFirst({ where: { code: 'student' } });
if (!studentRole) {
console.log('未找到学生角色');
return;
}
// 获取学生权限
const perms = await prisma.rolePermission.findMany({
where: { roleId: studentRole.id },
include: { permission: true }
});
const permCodes = perms.map(p => p.permission.code);
console.log('📋 学生权限:', permCodes.join(', '));
// 获取租户菜单
const tenantMenus = await prisma.tenantMenu.findMany({
where: { tenantId: studentRole.tenantId },
include: { menu: true }
});
// 过滤出学生能看到的菜单
const visibleMenus = tenantMenus
.filter(tm => tm.menu.permission && permCodes.includes(tm.menu.permission))
.map(tm => ({
name: tm.menu.name,
path: tm.menu.path,
permission: tm.menu.permission,
component: tm.menu.component,
}));
console.log('\n📱 学生可见菜单:');
visibleMenus.forEach(m => {
console.log(` - ${m.name}`);
console.log(` 路径: ${m.path}`);
console.log(` 权限: ${m.permission}`);
console.log(` 组件: ${m.component}`);
});
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,49 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 获取所有赛事活动相关菜单
const activityMenus = await prisma.menu.findMany({
where: { path: { startsWith: '/student-activities' } },
orderBy: { sort: 'asc' }
});
console.log('📋 赛事活动相关菜单:');
activityMenus.forEach(m => {
console.log(` ID: ${m.id} | ${m.name} | ${m.path}`);
});
// 获取租户1的菜单分配
console.log('\n📋 租户1的菜单分配:');
const tenantMenus = await prisma.tenantMenu.findMany({
where: { tenantId: 1 },
include: { menu: true }
});
const assignedMenuIds = tenantMenus.map(tm => tm.menuId);
// 检查哪些赛事活动菜单已分配
console.log('\n赛事活动菜单分配状态:');
activityMenus.forEach(m => {
const assigned = assignedMenuIds.includes(m.id);
console.log(` ${assigned ? '✅' : '❌'} ${m.name} (ID: ${m.id})`);
});
// 补充分配缺失的菜单
console.log('\n📝 补充分配缺失的菜单...');
for (const menu of activityMenus) {
if (!assignedMenuIds.includes(menu.id)) {
await prisma.tenantMenu.create({
data: { tenantId: 1, menuId: menu.id }
});
console.log(` ✅ 已分配: ${menu.name}`);
}
}
console.log('\n✅ 完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,127 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import * as dotenv from 'dotenv';
import * as path from 'path';
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 not found');
process.exit(1);
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 超级管理员专属权限(普通租户不应该有这些权限)
const superAdminOnlyPermissions = [
'tenant:create',
'tenant:update',
'tenant:delete',
];
async function cleanupTenantPermissions() {
try {
console.log('🚀 开始清理普通租户的超级管理员权限...\n');
// 1. 获取所有非超级租户
const normalTenants = await prisma.tenant.findMany({
where: {
isSuper: { not: 1 },
validState: 1,
},
});
console.log(`找到 ${normalTenants.length} 个普通租户\n`);
for (const tenant of normalTenants) {
console.log(`处理租户: ${tenant.name} (${tenant.code})`);
// 2. 找到该租户下的超级管理员专属权限
const permissionsToRemove = await prisma.permission.findMany({
where: {
tenantId: tenant.id,
code: { in: superAdminOnlyPermissions },
},
});
if (permissionsToRemove.length === 0) {
console.log(` ✓ 没有需要清理的权限\n`);
continue;
}
const permissionIds = permissionsToRemove.map((p) => p.id);
console.log(` 找到 ${permissionsToRemove.length} 个需要清理的权限: ${permissionsToRemove.map((p) => p.code).join(', ')}`);
// 3. 删除角色-权限关联
const deletedRolePermissions = await prisma.rolePermission.deleteMany({
where: {
permissionId: { in: permissionIds },
},
});
console.log(` 删除了 ${deletedRolePermissions.count} 条角色-权限关联`);
// 4. 删除权限记录
const deletedPermissions = await prisma.permission.deleteMany({
where: {
id: { in: permissionIds },
},
});
console.log(` 删除了 ${deletedPermissions.count} 条权限记录\n`);
}
// 5. 更新租户管理菜单权限
console.log('更新租户管理菜单权限...');
const tenantMenu = await prisma.menu.findFirst({
where: {
name: '租户管理',
path: '/system/tenants',
},
});
if (tenantMenu) {
if (tenantMenu.permission !== 'tenant:update') {
await prisma.menu.update({
where: { id: tenantMenu.id },
data: { permission: 'tenant:update' },
});
console.log(`✅ 菜单权限已更新为 tenant:update (原: ${tenantMenu.permission})`);
} else {
console.log('✅ 菜单权限已经是 tenant:update');
}
} else {
console.log('⚠️ 未找到租户管理菜单');
}
console.log('\n✅ 清理完成!');
console.log('\n说明:');
console.log(' - 普通租户现在只有 tenant:read 权限(用于读取租户列表)');
console.log(' - 租户管理菜单需要 tenant:update 权限才能看到');
console.log(' - 只有超级租户才有 tenant:create/update/delete 权限');
} catch (error) {
console.error('❌ 清理失败:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
cleanupTenantPermissions()
.then(() => {
console.log('\n🎉 脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 脚本执行失败:', error);
process.exit(1);
});

View File

@ -1,21 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const menus = await prisma.menu.findMany({
where: { path: { startsWith: '/student-activities' } },
orderBy: [{ parentId: 'asc' }, { sort: 'asc' }]
});
console.log('📋 赛事活动相关菜单:');
menus.forEach(m => {
console.log(`\n name: ${m.name}`);
console.log(` path: ${m.path}`);
console.log(` component: ${m.component || '(无)'}`);
console.log(` parentId: ${m.parentId || '(无 - 顶级菜单)'}`);
console.log(` permission: ${m.permission}`);
});
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,130 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const tenantId = 1;
// 获取租户分配的菜单ID
const tenantMenus = await prisma.tenantMenu.findMany({
where: { tenantId },
});
const menuIds = tenantMenus.map((tm) => tm.menuId);
console.log('📋 租户分配的菜单IDs:', menuIds.length, '个');
// 获取租户分配的所有菜单(包括父菜单)
const allMenus = await prisma.menu.findMany({
where: {
OR: [
{ id: { in: menuIds } },
{ children: { some: { id: { in: menuIds } } } },
],
validState: 1,
},
orderBy: {
sort: 'asc',
},
});
// 过滤出赛事活动相关的
console.log('\n📋 赛事活动相关菜单 (从数据库查询):');
const activityMenus = allMenus.filter(m => m.path?.startsWith('/student-activities'));
activityMenus.forEach(m => {
console.log(` ID: ${m.id} | ${m.name}`);
console.log(` path: ${m.path}`);
console.log(` component: ${m.component || '(null)'}`);
console.log(` parentId: ${m.parentId}`);
});
// 构建树形结构
const buildTree = (menus: any[], parentId: number | null = null): any[] => {
return menus
.filter((menu) => menu.parentId === parentId)
.map((menu) => ({
...menu,
children: buildTree(menus, menu.id),
}));
};
const menuTree = buildTree(allMenus);
// 找到赛事活动菜单树
const activityTree = menuTree.find(m => m.path === '/student-activities');
if (activityTree) {
console.log('\n📋 赛事活动菜单树 (模拟API返回):');
const printTree = (menu: any, indent = '') => {
console.log(`${indent}${menu.name} (${menu.path})`);
console.log(`${indent} component: ${menu.component || '(null)'}`);
if (menu.children && menu.children.length > 0) {
menu.children.forEach((child: any) => printTree(child, indent + ' '));
}
};
printTree(activityTree);
} else {
console.log('\n❌ 未找到赛事活动菜单树');
}
// 模拟权限过滤
const userPermissions = ['workbench:read', 'contest:activity:read', 'homework:student:read'];
const filterMenus = (menus: any[]): any[] => {
return menus
.map((menu) => {
let filteredChildren: any[] = [];
if (menu.children && menu.children.length > 0) {
filteredChildren = filterMenus(menu.children);
}
const hasPermissionField = menu.permission && menu.permission.trim() !== '';
if (hasPermissionField) {
if (!userPermissions.includes(menu.permission)) {
if (filteredChildren.length > 0) {
return { ...menu, children: filteredChildren };
}
return null;
}
}
if (!hasPermissionField) {
if (filteredChildren.length === 0 && (!menu.path || menu.path.trim() === '')) {
return null;
}
if (filteredChildren.length > 0) {
return { ...menu, children: filteredChildren };
}
return null;
}
const filtered = { ...menu };
if (filteredChildren.length > 0) {
filtered.children = filteredChildren;
}
return filtered;
})
.filter((menu) => menu !== null);
};
const filteredTree = filterMenus(menuTree);
const filteredActivityTree = filteredTree.find(m => m.path === '/student-activities');
if (filteredActivityTree) {
console.log('\n📋 权限过滤后的赛事活动菜单:');
const printTree = (menu: any, indent = '') => {
console.log(`${indent}${menu.name} (${menu.path})`);
console.log(`${indent} component: ${menu.component || '(null)'}`);
if (menu.children && menu.children.length > 0) {
menu.children.forEach((child: any) => printTree(child, indent + ' '));
}
};
printTree(filteredActivityTree);
console.log('\n📋 JSON格式 (检查component字段):');
console.log(JSON.stringify(filteredActivityTree, null, 2));
}
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,96 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 最终配置学生权限...\n');
// 获取学生角色
const studentRoles = await prisma.role.findMany({
where: { code: 'student' }
});
for (const studentRole of studentRoles) {
console.log(`\n处理学生角色 ID: ${studentRole.id}`);
// 学生最终需要的权限
const neededPerms = [
'workbench:read', // 工作台
'contest:activity:read', // 赛事活动
'homework:student:read', // 我的作业菜单
];
// 确保所有需要的权限都已分配
for (const permCode of neededPerms) {
const perm = await prisma.permission.findFirst({
where: { tenantId: studentRole.tenantId, code: permCode }
});
if (perm) {
const existing = await prisma.rolePermission.findFirst({
where: { roleId: studentRole.id, permissionId: perm.id }
});
if (!existing) {
await prisma.rolePermission.create({
data: { roleId: studentRole.id, permissionId: perm.id }
});
console.log(` ✅ 已添加: ${permCode}`);
} else {
console.log(` ⏭️ 已存在: ${permCode}`);
}
} else {
console.log(` ❌ 权限不存在: ${permCode}`);
}
}
// 移除不需要的权限
const permsToRemove = ['homework:read'];
for (const permCode of permsToRemove) {
const perm = await prisma.permission.findFirst({
where: { tenantId: studentRole.tenantId, code: permCode }
});
if (perm) {
const deleted = await prisma.rolePermission.deleteMany({
where: { roleId: studentRole.id, permissionId: perm.id }
});
if (deleted.count > 0) {
console.log(` 🗑️ 已移除: ${permCode}`);
}
}
}
}
// 更新学生作业菜单
console.log('\n📝 更新学生作业菜单...');
const studentMenu = await prisma.menu.findFirst({
where: { path: '/student-homework' }
});
if (studentMenu) {
await prisma.menu.update({
where: { id: studentMenu.id },
data: {
permission: 'homework:student:read',
component: 'homework/StudentList',
}
});
console.log(' ✅ 菜单已更新');
}
// 显示最终权限
console.log('\n📋 学生角色最终权限:');
for (const studentRole of studentRoles) {
const perms = await prisma.rolePermission.findMany({
where: { roleId: studentRole.id },
include: { permission: true }
});
console.log(`\n 角色 ID ${studentRole.id}:`);
perms.forEach(p => console.log(` - ${p.permission.code}: ${p.permission.name}`));
}
console.log('\n✅ 配置完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,46 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 修复重复的赛事活动菜单...\n');
// 查找原来的赛事活动菜单(在赛事管理下的,路径是 /contests/activities
const oldActivityMenu = await prisma.menu.findFirst({
where: { path: '/contests/activities' }
});
if (oldActivityMenu) {
// 把它改回 contest:read 权限,这样只有管理员能看到
await prisma.menu.update({
where: { id: oldActivityMenu.id },
data: { permission: 'contest:read' }
});
console.log('✅ 原赛事活动菜单权限更新为 contest:read');
}
// 验证最终结果
console.log('\n📋 最终菜单配置:');
const menus = await prisma.menu.findMany({
where: {
OR: [
{ path: '/contests/activities' },
{ path: '/student-activities' },
]
},
select: { name: true, path: true, permission: true, parentId: true }
});
menus.forEach(m => {
const type = m.parentId ? '(子菜单)' : '(一级菜单)';
console.log(` - ${m.name} ${type}`);
console.log(` 路径: ${m.path}`);
console.log(` 权限: ${m.permission}`);
});
console.log('\n✅ 修复完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,138 +0,0 @@
// 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);
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function fixInvalidDatetime() {
try {
console.log('🔧 开始修复无效的日期时间数据...\n');
// 获取当前时间作为默认值
const now = new Date();
// 修复 users 表中的无效日期
console.log('📋 修复 users 表中的 modify_time...');
const nowStr = now.toISOString().slice(0, 19).replace('T', ' ');
const userResult = await prisma.$executeRawUnsafe(`
UPDATE \`users\`
SET modify_time = '${nowStr}'
WHERE modify_time = '0000-00-00 00:00:00'
OR modify_time < '1970-01-01 00:00:00'
OR YEAR(modify_time) = 0
OR MONTH(modify_time) = 0
OR DAY(modify_time) = 0
`);
console.log(` ✅ 修复了 ${userResult} 条用户记录\n`);
// 修复其他所有包含 modify_time 的表
const tables = [
'tenants',
'roles',
'menus',
'permissions',
'dicts',
'dict_items',
'configs',
'schools',
'grades',
'departments',
'classes',
'teachers',
'students',
'contests',
'contest_teams',
'contest_team_members',
'contest_registrations',
'contest_works',
'contest_work_attachments',
'contest_work_scores',
'contest_work_judge_assignments',
'contest_judges',
'contest_notices',
'contest_results',
];
let totalFixed = userResult;
for (const table of tables) {
try {
// 使用 Prisma 的原始 SQL直接插入日期值
const result = await prisma.$executeRawUnsafe(`
UPDATE \`${table}\`
SET modify_time = '${nowStr}'
WHERE modify_time = '0000-00-00 00:00:00'
OR modify_time < '1970-01-01 00:00:00'
OR YEAR(modify_time) = 0
OR MONTH(modify_time) = 0
OR DAY(modify_time) = 0
`);
if (result > 0) {
console.log(`${table}: 修复了 ${result} 条记录`);
totalFixed += result;
}
} catch (error: any) {
// 如果表不存在或没有 modify_time 字段,跳过
if (
error.code !== 'P2025' &&
!error.message.includes('Unknown column')
) {
console.log(` ⚠️ ${table}: ${error.message}`);
}
}
}
console.log(`\n✅ 总共修复了 ${totalFixed} 条记录`);
console.log('\n💡 建议:检查 MySQL 的 sql_mode 设置,确保禁止无效日期');
console.log(' 可以在 my.cnf 或 my.ini 中添加:');
console.log(
' sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION"',
);
} catch (error) {
console.error('\n💥 修复失败:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
// 执行修复
fixInvalidDatetime()
.then(() => {
console.log('\n🎉 修复脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 修复脚本执行失败:', error);
process.exit(1);
});

View File

@ -1,76 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 修复学生赛事活动菜单...\n');
// 获取所有租户
const tenants = await prisma.tenant.findMany({
select: { id: true, name: true }
});
// 检查是否已存在独立的赛事活动菜单
let studentActivityMenu = await prisma.menu.findFirst({
where: { path: '/student-activities' }
});
if (!studentActivityMenu) {
// 创建独立的赛事活动菜单(一级菜单)
studentActivityMenu = await prisma.menu.create({
data: {
name: '赛事活动',
path: '/student-activities',
component: 'contests/Activities',
icon: 'TrophyOutlined',
permission: 'contest:activity:read',
sort: 10, // 放在工作台之后
parentId: null, // 一级菜单
validState: 1,
}
});
console.log('✅ 创建菜单: 赛事活动 (独立路由)');
} else {
console.log('⏭️ 菜单已存在: 赛事活动');
}
// 为每个租户分配菜单
for (const tenant of tenants) {
const existing = await prisma.tenantMenu.findFirst({
where: { tenantId: tenant.id, menuId: studentActivityMenu.id }
});
if (!existing) {
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: studentActivityMenu.id,
}
});
console.log(`✅ 菜单已分配给租户: ${tenant.name}`);
}
}
// 更新原有的赛事活动菜单(在赛事管理下的)权限,确保只有管理员能看到
const oldActivityMenu = await prisma.menu.findFirst({
where: {
path: '/contests/activities',
parentId: { not: null } // 有父菜单的
}
});
if (oldActivityMenu) {
// 保持原有权限 contest:read这样管理员可以看到
console.log('⏭️ 原赛事活动菜单保持不变(管理员使用)');
}
console.log('\n✅ 配置完成!');
console.log('\n学生菜单结构:');
console.log(' - 工作台');
console.log(' - 赛事活动 (独立路由 /student-activities)');
console.log(' - 我的作业 (独立路由 /student-homework)');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,46 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const studentRole = await prisma.role.findFirst({ where: { code: 'student' } });
if (!studentRole) {
console.log('未找到学生角色');
return;
}
// 需要的权限
const neededPerms = ['workbench:read', 'homework:read', 'contest:activity:read'];
for (const permCode of neededPerms) {
const perm = await prisma.permission.findFirst({
where: { tenantId: studentRole.tenantId, code: permCode }
});
if (perm) {
const existing = await prisma.rolePermission.findFirst({
where: { roleId: studentRole.id, permissionId: perm.id }
});
if (!existing) {
await prisma.rolePermission.create({
data: { roleId: studentRole.id, permissionId: perm.id }
});
console.log(`✅ 已添加: ${permCode}`);
} else {
console.log(`⏭️ 已存在: ${permCode}`);
}
} else {
console.log(`❌ 权限不存在: ${permCode}`);
}
}
// 显示最终权限
const perms = await prisma.rolePermission.findMany({
where: { roleId: studentRole.id },
include: { permission: true }
});
console.log('\n📋 学生最终权限:');
perms.forEach(p => console.log(` - ${p.permission.code}: ${p.permission.name}`));
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,84 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 修复工作台权限配置...\n');
// 1. 获取所有租户
const tenants = await prisma.tenant.findMany({
select: { id: true, name: true }
});
for (const tenant of tenants) {
console.log(`📝 处理租户: ${tenant.name} (ID: ${tenant.id})`);
// 2. 检查/创建 workbench:read 权限
let perm = await prisma.permission.findFirst({
where: { tenantId: tenant.id, code: 'workbench:read' }
});
if (!perm) {
perm = await prisma.permission.create({
data: {
tenantId: tenant.id,
code: 'workbench:read',
name: '查看工作台',
resource: 'workbench',
action: 'read',
description: '访问工作台页面',
}
});
console.log(' ✅ 创建权限: workbench:read');
} else {
console.log(' ⏭️ 权限已存在: workbench:read');
}
// 3. 为该租户的所有角色分配此权限
const roles = await prisma.role.findMany({
where: { tenantId: tenant.id },
select: { id: true, name: true, code: true }
});
for (const role of roles) {
const existing = await prisma.rolePermission.findFirst({
where: { roleId: role.id, permissionId: perm.id }
});
if (!existing) {
await prisma.rolePermission.create({
data: { roleId: role.id, permissionId: perm.id }
});
console.log(` ✅ 分配给角色: ${role.name}`);
} else {
console.log(` ⏭️ 角色 ${role.name} 已有此权限`);
}
}
}
// 4. 更新工作台菜单的权限配置
console.log('\n🔧 更新工作台菜单权限配置...');
const workbenchMenu = await prisma.menu.findFirst({
where: { name: '工作台' }
});
if (workbenchMenu) {
if (!workbenchMenu.permission || workbenchMenu.permission.trim() === '') {
await prisma.menu.update({
where: { id: workbenchMenu.id },
data: { permission: 'workbench:read' }
});
console.log('✅ 工作台菜单已设置 permission: workbench:read');
} else {
console.log(`⏭️ 工作台菜单已有权限: ${workbenchMenu.permission}`);
}
} else {
console.log('⚠️ 未找到工作台菜单');
}
console.log('\n✅ 完成!请重新登录以查看更新后的菜单。');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,210 @@
// 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);
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🚀 开始创建 LinkSea 普通租户...\n');
const tenantCode = 'linksea';
const menuNames = ['赛事管理', '系统管理'];
// 1. 查找或创建租户
console.log(`📋 步骤 1: 查找或创建租户 "${tenantCode}"...`);
let tenant = await prisma.tenant.findUnique({
where: { code: tenantCode },
});
if (!tenant) {
// 创建普通租户
tenant = await prisma.tenant.create({
data: {
name: 'LinkSea 租户',
code: tenantCode,
domain: tenantCode,
description: 'LinkSea 普通租户',
isSuper: 0,
validState: 1,
},
});
console.log(`✅ 租户创建成功: ${tenant.name} (${tenant.code})\n`);
} else {
if (tenant.validState !== 1) {
console.error(`❌ 错误: 租户 "${tenantCode}" 状态无效!`);
process.exit(1);
}
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
}
// 2. 查找指定的菜单(顶级菜单)
console.log(`📋 步骤 2: 查找菜单 "${menuNames.join('", "')}"...`);
const menus = await prisma.menu.findMany({
where: {
name: { in: menuNames },
parentId: null, // 只查找顶级菜单
validState: 1,
},
});
if (menus.length === 0) {
console.error(`❌ 错误: 未找到指定的菜单!`);
console.error(` 请确保菜单 "${menuNames.join('", "')}" 已初始化`);
console.error(` 运行: pnpm init:menus`);
process.exit(1);
}
if (menus.length !== menuNames.length) {
const foundMenuNames = menus.map((m) => m.name);
const missingMenus = menuNames.filter(
(name) => !foundMenuNames.includes(name),
);
console.warn(`⚠️ 警告: 部分菜单未找到: ${missingMenus.join(', ')}`);
console.log(` 找到的菜单: ${foundMenuNames.join(', ')}\n`);
} else {
console.log(`✅ 找到 ${menus.length} 个菜单:`);
menus.forEach((menu) => {
console.log(`${menu.name}`);
});
console.log('');
}
// 3. 递归获取菜单及其所有子菜单
console.log(`📋 步骤 3: 获取菜单及其所有子菜单...`);
const menuIds = new Set<number>();
// 递归函数获取菜单及其所有子菜单的ID
async function getMenuAndChildrenIds(menuId: number) {
menuIds.add(menuId);
// 获取所有子菜单
const children = await prisma.menu.findMany({
where: {
parentId: menuId,
validState: 1,
},
});
// 递归获取子菜单的子菜单
for (const child of children) {
await getMenuAndChildrenIds(child.id);
}
}
// 为每个顶级菜单获取所有子菜单
for (const menu of menus) {
await getMenuAndChildrenIds(menu.id);
}
const menuIdArray = Array.from(menuIds);
console.log(`✅ 共找到 ${menuIdArray.length} 个菜单(包括子菜单)\n`);
// 4. 获取租户已分配的菜单
console.log(`📋 步骤 4: 检查租户已分配的菜单...`);
const existingTenantMenus = await prisma.tenantMenu.findMany({
where: {
tenantId: tenant.id,
},
select: {
menuId: true,
},
});
const existingMenuIds = new Set(existingTenantMenus.map((tm) => tm.menuId));
// 5. 为租户分配菜单(只分配新的菜单)
console.log(`📋 步骤 5: 为租户分配菜单...`);
const menusToAdd = menuIdArray.filter((id) => !existingMenuIds.has(id));
if (menusToAdd.length === 0) {
console.log(`✅ 租户已拥有所有指定的菜单\n`);
} else {
let addedCount = 0;
const menuNamesToAdd: string[] = [];
for (const menuId of menusToAdd) {
const menu = await prisma.menu.findUnique({
where: { id: menuId },
select: { name: true },
});
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: menuId,
},
});
addedCount++;
if (menu) {
menuNamesToAdd.push(menu.name);
}
}
console.log(`✅ 为租户添加了 ${addedCount} 个菜单:`);
menuNamesToAdd.forEach((name) => {
console.log(`${name}`);
});
console.log(
`\n✅ 租户现在拥有 ${menuIdArray.length} 个菜单(包括子菜单)\n`,
);
}
// 6. 验证结果
console.log('📊 初始化结果:');
console.log('========================================');
console.log('租户信息:');
console.log(` 租户编码: ${tenant.code}`);
console.log(` 租户名称: ${tenant.name}`);
console.log(` 租户类型: ${tenant.isSuper === 1 ? '超级租户' : '普通租户'}`);
console.log(` 访问链接: http://your-domain.com/?tenant=${tenant.code}`);
console.log('========================================');
console.log('分配的菜单:');
console.log(` 顶级菜单: ${menuNames.join(', ')}`);
console.log(` 菜单总数: ${menuIdArray.length} 个(包括子菜单)`);
console.log('========================================');
console.log('\n💡 提示:');
console.log(' 如需创建管理员账号,请运行: pnpm init:tenant-admin linksea');
console.log('========================================');
}
main()
.then(() => {
console.log('\n🎉 LinkSea 租户创建脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 LinkSea 租户创建脚本执行失败:', error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -44,6 +44,9 @@ if (!fs.existsSync(menusFilePath)) {
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8'));
// 超级租户可见的菜单名称
const SUPER_TENANT_MENUS = ['工作台', '赛事活动', '赛事管理', '系统管理'];
async function initMenus() {
try {
console.log('🚀 开始初始化菜单数据...\n');
@ -105,8 +108,10 @@ async function initMenus() {
}
// 清空现有菜单(重新初始化)
console.log('🗑️ 清空现有菜单...');
// 先删除所有子菜单,再删除父菜单(避免外键约束问题)
console.log('🗑️ 清空现有菜单和租户菜单关联...');
// 先删除租户菜单关联
await prisma.tenantMenu.deleteMany({});
// 再删除所有子菜单,再删除父菜单(避免外键约束问题)
await prisma.menu.deleteMany({
where: {
parentId: {
@ -163,7 +168,7 @@ async function initMenus() {
printMenuTree(menu);
});
// 为所有现有租户分配菜单
// 为所有现有租户分配菜单(区分超级租户和普通租户)
console.log(`\n📋 为所有租户分配菜单...`);
const allTenants = await prisma.tenant.findMany({
where: { validState: 1 },
@ -174,40 +179,46 @@ async function initMenus() {
} else {
console.log(` 找到 ${allTenants.length} 个租户\n`);
for (const tenant of allTenants) {
// 获取租户已分配的菜单
const existingTenantMenus = await prisma.tenantMenu.findMany({
where: { tenantId: tenant.id },
select: { menuId: true },
});
const existingMenuIds = new Set(
existingTenantMenus.map((tm) => tm.menuId),
);
// 为租户分配所有新菜单
let addedMenuCount = 0;
for (const menu of allMenus) {
if (!existingMenuIds.has(menu.id)) {
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: menu.id,
},
});
addedMenuCount++;
// 获取超级租户专属菜单ID工作台、赛事管理、系统管理及其子菜单
const superTenantMenuIds = new Set<number>();
for (const menu of allMenus) {
// 顶级菜单
if (!menu.parentId && SUPER_TENANT_MENUS.includes(menu.name)) {
superTenantMenuIds.add(menu.id);
}
// 子菜单(检查父菜单是否在超级租户菜单中)
if (menu.parentId) {
const parentMenu = allMenus.find(m => m.id === menu.parentId);
if (parentMenu && SUPER_TENANT_MENUS.includes(parentMenu.name)) {
superTenantMenuIds.add(menu.id);
}
}
}
if (addedMenuCount > 0) {
console.log(
` ✓ 租户 "${tenant.name}" (${tenant.code}): 添加了 ${addedMenuCount} 个菜单`,
);
} else {
console.log(
` ✓ 租户 "${tenant.name}" (${tenant.code}): 已拥有所有菜单`,
);
for (const tenant of allTenants) {
const isSuperTenant = tenant.isSuper === 1;
// 确定要分配的菜单
const menusToAssign = isSuperTenant
? allMenus.filter(m => superTenantMenuIds.has(m.id))
: allMenus;
// 为租户分配菜单
let addedMenuCount = 0;
for (const menu of menusToAssign) {
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: menu.id,
},
});
addedMenuCount++;
}
const tenantType = isSuperTenant ? '(超级租户)' : '(普通租户)';
console.log(
` ✓ 租户 "${tenant.name}" ${tenantType}: 分配了 ${addedMenuCount} 个菜单`,
);
}
console.log(`\n✅ 菜单分配完成!`);
}

View File

@ -0,0 +1,561 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import * as dotenv from 'dotenv';
import * as path from 'path';
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 not found');
process.exit(1);
}
import { PrismaClient } from '@prisma/client';
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: '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: '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: '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: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: 'registration:approve', resource: 'registration', action: 'approve', 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: 'review:read', resource: 'review', action: 'read', name: '查看评审任务', description: '允许查看待评审作品' },
{ code: 'review:score', resource: 'review', action: 'score', 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: '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: '允许查看作业评分' },
// 字典管理
{ 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: 'activity:read', resource: 'activity', action: 'read', name: '查看赛事活动', description: '允许查看已发布的赛事活动' },
{ code: 'activity:guidance', resource: 'activity', action: 'guidance', name: '指导学生', description: '允许指导学生参赛' },
];
// ============================================
// 角色定义和权限映射
// ============================================
// 超级租户角色
const superTenantRoles = [
{
code: 'super_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',
'tenant:create', 'tenant:read', 'tenant:update', 'tenant:delete',
'dict:create', 'dict:read', 'dict:update', 'dict:delete',
'config:create', 'config:read', 'config:update', 'config:delete',
'log:read', 'log:delete',
// 赛事管理
'contest:create', 'contest:read', 'contest:update', 'contest:delete', 'contest:publish', 'contest:finish',
'review-rule:create', 'review-rule:read', 'review-rule:update', 'review-rule:delete',
'judge:create', 'judge:read', 'judge:update', 'judge:delete', 'judge:assign',
'registration:read', 'registration:approve',
'work:read',
'notice:create', 'notice:read', 'notice:update', 'notice:delete',
],
},
{
code: 'judge',
name: '评委',
description: '赛事评委,可以评审作品',
permissions: [
'workbench:read',
'activity:read', // 查看赛事活动
'work:read', // 查看待评审作品
'review:read', // 查看评审任务
'review:score', // 评审打分
'notice:read', // 查看公告
],
},
];
// 普通租户(学校)角色
const normalTenantRoles = [
{
code: 'school_admin',
name: '学校管理员',
description: '学校管理员,管理学校信息、教师、学生等',
permissions: [
'workbench:read',
'user:create', 'user:read', 'user:update', 'user:delete',
'role:read',
'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',
],
},
{
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', // 查看自己的作业评分
],
},
];
// ============================================
// 初始化函数
// ============================================
/**
*
*/
async function createPermissions(tenantId: number, permissionCodes: string[]) {
const createdPermissions: { [code: string]: number } = {};
for (const code of permissionCodes) {
const permDef = allPermissions.find(p => p.code === code);
if (!permDef) {
console.log(` ⚠️ 权限定义不存在: ${code}`);
continue;
}
// 检查是否已存在
let permission = await prisma.permission.findFirst({
where: { tenantId, code },
});
if (!permission) {
permission = await prisma.permission.create({
data: {
tenantId,
code: permDef.code,
resource: permDef.resource,
action: permDef.action,
name: permDef.name,
description: permDef.description,
validState: 1,
},
});
console.log(` ✓ 创建权限: ${code}`);
}
createdPermissions[code] = permission.id;
}
return createdPermissions;
}
/**
*
*/
async function createRoleWithPermissions(
tenantId: number,
roleConfig: { code: string; name: string; description: string; permissions: string[] },
permissionMap: { [code: string]: number }
) {
// 创建或获取角色
let role = await prisma.role.findFirst({
where: { tenantId, code: roleConfig.code },
});
if (!role) {
role = await prisma.role.create({
data: {
tenantId,
code: roleConfig.code,
name: roleConfig.name,
description: roleConfig.description,
validState: 1,
},
});
console.log(` ✓ 创建角色: ${roleConfig.name} (${roleConfig.code})`);
} else {
// 更新角色信息
role = await prisma.role.update({
where: { id: role.id },
data: {
name: roleConfig.name,
description: roleConfig.description,
},
});
console.log(` ✓ 更新角色: ${roleConfig.name} (${roleConfig.code})`);
}
// 分配权限
const existingRolePermissions = await prisma.rolePermission.findMany({
where: { roleId: role.id },
select: { permissionId: true },
});
const existingPermissionIds = new Set(existingRolePermissions.map(rp => rp.permissionId));
let addedCount = 0;
for (const permCode of roleConfig.permissions) {
const permissionId = permissionMap[permCode];
if (!permissionId) {
console.log(` ⚠️ 权限不存在: ${permCode}`);
continue;
}
if (!existingPermissionIds.has(permissionId)) {
await prisma.rolePermission.create({
data: {
roleId: role.id,
permissionId,
},
});
addedCount++;
}
}
if (addedCount > 0) {
console.log(` 添加了 ${addedCount} 个权限`);
}
return role;
}
/**
*
*/
async function initSuperTenantRoles() {
console.log('\n🚀 开始初始化超级租户角色和权限...\n');
// 查找超级租户
const superTenant = await prisma.tenant.findFirst({
where: { isSuper: 1, validState: 1 },
});
if (!superTenant) {
console.error('❌ 超级租户不存在!请先运行 init:super-tenant');
return;
}
console.log(`找到超级租户: ${superTenant.name} (${superTenant.code})\n`);
// 收集所有需要的权限码
const allPermissionCodes = new Set<string>();
superTenantRoles.forEach(role => {
role.permissions.forEach(code => allPermissionCodes.add(code));
});
// 创建权限
console.log('📝 创建权限...');
const permissionMap = await createPermissions(superTenant.id, Array.from(allPermissionCodes));
console.log(`✅ 共 ${Object.keys(permissionMap).length} 个权限\n`);
// 创建角色
console.log('👥 创建角色...');
for (const roleConfig of superTenantRoles) {
await createRoleWithPermissions(superTenant.id, roleConfig, permissionMap);
}
console.log('\n✅ 超级租户角色和权限初始化完成!');
}
/**
*
*/
async function initNormalTenantRoles(tenantCode: string) {
console.log(`\n🚀 开始初始化租户 "${tenantCode}" 的角色和权限...\n`);
// 查找租户
const tenant = await prisma.tenant.findFirst({
where: { code: tenantCode, validState: 1 },
});
if (!tenant) {
console.error(`❌ 租户 "${tenantCode}" 不存在!`);
return;
}
if (tenant.isSuper === 1) {
console.log('⚠️ 这是超级租户,请使用 --super 选项');
return;
}
console.log(`找到租户: ${tenant.name} (${tenant.code})\n`);
// 收集所有需要的权限码
const allPermissionCodes = new Set<string>();
normalTenantRoles.forEach(role => {
role.permissions.forEach(code => allPermissionCodes.add(code));
});
// 创建权限
console.log('📝 创建权限...');
const permissionMap = await createPermissions(tenant.id, Array.from(allPermissionCodes));
console.log(`✅ 共 ${Object.keys(permissionMap).length} 个权限\n`);
// 创建角色
console.log('👥 创建角色...');
for (const roleConfig of normalTenantRoles) {
await createRoleWithPermissions(tenant.id, roleConfig, permissionMap);
}
// 输出角色信息
console.log('\n📊 角色权限概览:');
for (const roleConfig of normalTenantRoles) {
console.log(` ${roleConfig.name} (${roleConfig.code}): ${roleConfig.permissions.length} 个权限`);
}
console.log(`\n✅ 租户 "${tenantCode}" 角色和权限初始化完成!`);
}
/**
*
*/
async function initAllNormalTenantRoles() {
console.log('\n🚀 开始初始化所有普通租户的角色和权限...\n');
// 查找所有普通租户
const normalTenants = await prisma.tenant.findMany({
where: { isSuper: { not: 1 }, validState: 1 },
});
if (normalTenants.length === 0) {
console.log('⚠️ 没有找到普通租户');
return;
}
console.log(`找到 ${normalTenants.length} 个普通租户\n`);
for (const tenant of normalTenants) {
await initNormalTenantRoles(tenant.code);
console.log('');
}
console.log('\n✅ 所有普通租户角色和权限初始化完成!');
}
// ============================================
// 主函数
// ============================================
async function main() {
const args = process.argv.slice(2);
const isSuper = args.includes('--super');
const isAll = args.includes('--all');
const tenantCode = args.find(arg => !arg.startsWith('--'));
try {
if (isSuper) {
await initSuperTenantRoles();
} else if (isAll) {
await initAllNormalTenantRoles();
} else if (tenantCode) {
await initNormalTenantRoles(tenantCode);
} else {
console.log('使用方法:');
console.log(' 初始化超级租户角色: ts-node scripts/init-roles-permissions.ts --super');
console.log(' 初始化指定租户角色: ts-node scripts/init-roles-permissions.ts <租户编码>');
console.log(' 初始化所有普通租户: ts-node scripts/init-roles-permissions.ts --all');
process.exit(1);
}
} finally {
await prisma.$disconnect();
}
}
main()
.then(() => {
console.log('\n🎉 脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 脚本执行失败:', error);
process.exit(1);
});

View File

@ -230,7 +230,7 @@ async function main() {
icon: 'TeamOutlined',
component: 'system/tenants/Index',
parentId: systemMenu.id,
permission: 'tenant:read',
permission: 'tenant:update', // 只有超级租户才有此权限,普通租户只有 tenant:read
sort: 7,
validState: 1,
},

View File

@ -254,6 +254,563 @@ const permissions = [
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: '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:team:create',
resource: 'contest:team',
action: 'create',
name: '创建团队',
description: '允许创建比赛团队',
},
{
code: 'contest:team:read',
resource: 'contest:team',
action: 'read',
name: '查看团队',
description: '允许查看团队列表和详情',
},
{
code: 'contest:team:update',
resource: 'contest:team',
action: 'update',
name: '更新团队',
description: '允许更新团队信息',
},
{
code: 'contest:team:delete',
resource: 'contest:team',
action: 'delete',
name: '删除团队',
description: '允许删除团队',
},
{
code: 'contest:team:manage',
resource: 'contest:team',
action: 'manage',
name: '管理团队成员',
description: '允许管理团队成员',
},
{
code: 'contest:review:create',
resource: 'contest:review',
action: 'create',
name: '创建评审规则',
description: '允许创建评审规则',
},
{
code: 'contest:review:read',
resource: 'contest:review',
action: 'read',
name: '查看评审',
description: '允许查看评审规则和评审记录',
},
{
code: 'contest:review:update',
resource: 'contest:review',
action: 'update',
name: '更新评审规则',
description: '允许更新评审规则',
},
{
code: 'contest:review:delete',
resource: 'contest:review',
action: 'delete',
name: '删除评审规则',
description: '允许删除评审规则',
},
{
code: 'contest:review:assign',
resource: 'contest:review',
action: 'assign',
name: '分配评审任务',
description: '允许分配评审任务给评委',
},
{
code: 'contest:review:score',
resource: 'contest:review',
action: 'score',
name: '评审打分',
description: '允许对作品进行评审打分',
},
{
code: 'contest:judge:create',
resource: 'contest:judge',
action: 'create',
name: '添加评委',
description: '允许添加比赛评委',
},
{
code: 'contest:judge:read',
resource: 'contest:judge',
action: 'read',
name: '查看评委',
description: '允许查看评委列表',
},
{
code: 'contest:judge:update',
resource: 'contest:judge',
action: 'update',
name: '更新评委',
description: '允许更新评委信息',
},
{
code: 'contest:judge:delete',
resource: 'contest:judge',
action: 'delete',
name: '删除评委',
description: '允许删除评委',
},
{
code: 'contest:work:create',
resource: 'contest:work',
action: 'create',
name: '创建作品',
description: '允许创建参赛作品',
},
{
code: 'contest:work:read',
resource: 'contest:work',
action: 'read',
name: '查看作品',
description: '允许查看作品列表和详情',
},
{
code: 'contest:work:update',
resource: 'contest:work',
action: 'update',
name: '更新作品',
description: '允许更新作品信息',
},
{
code: 'contest:work:delete',
resource: 'contest:work',
action: 'delete',
name: '删除作品',
description: '允许删除作品',
},
{
code: 'contest:work:submit',
resource: 'contest:work',
action: 'submit',
name: '提交作品',
description: '允许提交作品',
},
{
code: 'contest:work:review',
resource: 'contest:work',
action: 'review',
name: '审核作品',
description: '允许审核作品状态',
},
{
code: 'contest:registration:create',
resource: 'contest:registration',
action: 'create',
name: '创建报名',
description: '允许创建报名记录',
},
{
code: 'contest:registration:read',
resource: 'contest:registration',
action: 'read',
name: '查看报名',
description: '允许查看报名列表和详情',
},
{
code: 'contest:registration:update',
resource: 'contest:registration',
action: 'update',
name: '更新报名',
description: '允许更新报名信息',
},
{
code: 'contest:registration:delete',
resource: 'contest:registration',
action: 'delete',
name: '删除报名',
description: '允许删除报名记录',
},
{
code: 'contest:registration:approve',
resource: 'contest:registration',
action: 'approve',
name: '审核报名',
description: '允许审核报名(通过/拒绝)',
},
{
code: 'contest:notice:create',
resource: 'contest:notice',
action: 'create',
name: '创建公告',
description: '允许创建比赛公告',
},
{
code: 'contest:notice:read',
resource: 'contest:notice',
action: 'read',
name: '查看公告',
description: '允许查看公告列表和详情',
},
{
code: 'contest:notice:update',
resource: 'contest:notice',
action: 'update',
name: '更新公告',
description: '允许更新公告信息',
},
{
code: 'contest:notice:delete',
resource: 'contest:notice',
action: 'delete',
name: '删除公告',
description: '允许删除公告',
},
{
code: 'contest:notice:publish',
resource: 'contest:notice',
action: 'publish',
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: 'tenant:read',
resource: 'tenant',
action: 'read',
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: '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: 'work:read',
resource: 'work',
action: 'read',
name: '查看作品',
description: '允许查看参赛作品',
},
];
// 超级管理员专属权限(只有超级租户才能拥有)
const superAdminPermissions = [
{
code: 'tenant:create',
resource: 'tenant',
action: 'create',
name: '创建租户',
description: '允许创建租户(仅超级管理员)',
},
{
code: 'tenant:update',
resource: 'tenant',
action: 'update',
name: '更新租户',
description: '允许更新租户信息(仅超级管理员)',
},
{
code: 'tenant:delete',
resource: 'tenant',
action: 'delete',
name: '删除租户',
description: '允许删除租户(仅超级管理员)',
},
];
/**
@ -280,7 +837,8 @@ async function initTenantAdminPermissionsOnly(tenantCode: string) {
process.exit(1);
}
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
const isSuperTenant = tenant.isSuper === 1;
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})${isSuperTenant ? ' [超级租户]' : ''}\n`);
// 2. 检查 admin 角色是否存在
console.log(`👤 步骤 2: 检查 admin 角色是否存在...`);
@ -301,10 +859,15 @@ async function initTenantAdminPermissionsOnly(tenantCode: string) {
console.log(`✅ 找到 admin 角色: ${adminRole.name} (${adminRole.code})\n`);
// 3. 初始化租户权限(如果不存在则创建)
console.log(`📝 步骤 3: 初始化租户权限...`);
// 超级租户拥有所有权限,普通租户只拥有基础权限
const allPermissions = isSuperTenant
? [...permissions, ...superAdminPermissions]
: permissions;
console.log(`📝 步骤 3: 初始化租户权限...${isSuperTenant ? '(包含超级管理员权限)' : ''}`);
const createdPermissions = [];
for (const perm of permissions) {
for (const perm of allPermissions) {
// 检查权限是否已存在
const existingPermission = await prisma.permission.findFirst({
where: {
@ -448,7 +1011,8 @@ async function initTenantAdmin(tenantCode: string) {
process.exit(1);
}
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
const isSuperTenant = tenant.isSuper === 1;
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})${isSuperTenant ? ' [超级租户]' : ''}\n`);
// 2. 检查是否已存在 admin 用户
console.log(`👤 步骤 2: 检查 admin 用户是否已存在...`);
@ -468,10 +1032,15 @@ async function initTenantAdmin(tenantCode: string) {
}
// 3. 初始化租户权限(如果不存在则创建)
console.log(`📝 步骤 3: 初始化租户权限...`);
// 超级租户拥有所有权限,普通租户只拥有基础权限
const allPermissions = isSuperTenant
? [...permissions, ...superAdminPermissions]
: permissions;
console.log(`📝 步骤 3: 初始化租户权限...${isSuperTenant ? '(包含超级管理员权限)' : ''}`);
const createdPermissions = [];
for (const perm of permissions) {
for (const perm of allPermissions) {
// 检查权限是否已存在
const existingPermission = await prisma.permission.findFirst({
where: {

View File

@ -1,58 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 把"我的作业"移到"作业管理"下...\n');
// 1. 查找作业管理父菜单
const homeworkParent = await prisma.menu.findFirst({
where: { name: '作业管理', parentId: null }
});
if (!homeworkParent) {
console.log('❌ 未找到作业管理菜单');
return;
}
console.log('✅ 找到作业管理菜单 ID:', homeworkParent.id);
// 2. 查找我的作业菜单
const studentHomework = await prisma.menu.findFirst({
where: { path: '/student-homework' }
});
if (!studentHomework) {
console.log('❌ 未找到我的作业菜单');
return;
}
console.log('✅ 找到我的作业菜单 ID:', studentHomework.id);
// 3. 更新我的作业菜单,设置为作业管理的子菜单
await prisma.menu.update({
where: { id: studentHomework.id },
data: {
parentId: homeworkParent.id,
path: '/homework/student',
sort: 0, // 排在最前面
}
});
console.log('✅ 已更新我的作业为作业管理的子菜单');
// 4. 显示最终作业管理菜单结构
console.log('\n📋 作业管理最终菜单结构:');
const children = await prisma.menu.findMany({
where: { parentId: homeworkParent.id },
orderBy: { sort: 'asc' }
});
console.log(`- ${homeworkParent.name} (${homeworkParent.path})`);
children.forEach(c => {
console.log(` - ${c.name} (${c.path}) | component: ${c.component || '(null)'} | permission: ${c.permission}`);
});
console.log('\n✅ 完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,102 +0,0 @@
// 加载环境变量(必须在其他导入之前)
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') });
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function queryContestSubmitTime() {
try {
const contestName = '3D打印作品大赛2';
const contest = await prisma.contest.findFirst({
where: {
contestName: {
contains: contestName,
},
validState: 1,
},
select: {
id: true,
contestName: true,
submitStartTime: true,
submitEndTime: true,
submitRule: true,
startTime: true,
endTime: true,
},
});
if (!contest) {
console.log(`未找到名为"${contestName}"的比赛`);
// 尝试查找包含"3D打印"的比赛
const contests = await prisma.contest.findMany({
where: {
contestName: {
contains: '3D打印',
},
validState: 1,
},
select: {
id: true,
contestName: true,
submitStartTime: true,
submitEndTime: true,
},
orderBy: {
createTime: 'desc',
},
});
if (contests.length > 0) {
console.log('\n找到以下包含"3D打印"的比赛:');
contests.forEach((c) => {
console.log(`\n比赛名称: ${c.contestName}`);
console.log(`作品提交开始时间: ${c.submitStartTime}`);
console.log(`作品提交结束时间: ${c.submitEndTime}`);
});
} else {
console.log('未找到任何包含"3D打印"的比赛');
}
return;
}
console.log('='.repeat(60));
console.log('比赛信息');
console.log('='.repeat(60));
console.log(`比赛ID: ${contest.id}`);
console.log(`比赛名称: ${contest.contestName}`);
console.log(`\n作品提交时间:`);
console.log(` 开始时间: ${contest.submitStartTime}`);
console.log(` 结束时间: ${contest.submitEndTime}`);
console.log(` 提交规则: ${contest.submitRule === 'once' ? '单次提交' : '可多次提交'}`);
console.log(`\n比赛时间:`);
console.log(` 开始时间: ${contest.startTime}`);
console.log(` 结束时间: ${contest.endTime}`);
console.log('='.repeat(60));
} catch (error) {
console.error('查询失败:', error);
} finally {
await prisma.$disconnect();
}
}
queryContestSubmitTime();

View File

@ -1,21 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const result = await prisma.menu.updateMany({
where: { path: '/student-activities/list' },
data: { name: '活动列表' }
});
console.log('✅ 已更新', result.count, '条记录');
// 验证
const menu = await prisma.menu.findFirst({
where: { path: '/student-activities/list' }
});
if (menu) {
console.log('更新后:', menu.name, '|', menu.path);
}
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,38 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const contestMenu = await prisma.menu.findFirst({
where: { name: '赛事管理', parentId: null }
});
if (!contestMenu) {
console.log('未找到赛事管理菜单');
return;
}
const children = await prisma.menu.findMany({
where: { parentId: contestMenu.id },
orderBy: { sort: 'asc' }
});
console.log('📋 赛事管理菜单结构:');
console.log(`- ${contestMenu.name} (ID: ${contestMenu.id}, path: ${contestMenu.path})`);
children.forEach(c => {
console.log(` - ${c.name}`);
console.log(` path: ${c.path}`);
console.log(` component: ${c.component}`);
console.log(` permission: ${c.permission}`);
});
// 检查是否有评委管理和评审进度
console.log('\n📋 检查缺失的菜单:');
const judgeMenu = children.find(c => c.name === '评委管理');
const reviewProgressMenu = children.find(c => c.name === '评审进度');
console.log('评委管理:', judgeMenu ? '存在' : '❌ 不存在');
console.log('评审进度:', reviewProgressMenu ? '存在' : '❌ 不存在');
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,31 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 作业管理菜单
const homeworkParent = await prisma.menu.findFirst({
where: { name: '作业管理', parentId: null }
});
if (!homeworkParent) {
console.log('未找到作业管理菜单');
return;
}
const children = await prisma.menu.findMany({
where: { parentId: homeworkParent.id },
orderBy: { sort: 'asc' }
});
console.log('📋 作业管理菜单结构:');
console.log(`- ${homeworkParent.name} (${homeworkParent.path})`);
children.forEach(c => {
console.log(` - ${c.name}`);
console.log(` path: ${c.path}`);
console.log(` component: ${c.component}`);
console.log(` permission: ${c.permission}`);
});
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -1,156 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 更新赛事活动菜单结构...\n');
// 1. 删除赛事管理下的赛事活动菜单
console.log('📝 步骤1: 移除赛事管理下的赛事活动菜单');
const oldActivityMenu = await prisma.menu.findFirst({
where: { path: '/contests/activities' }
});
if (oldActivityMenu) {
// 先删除租户菜单关联
await prisma.tenantMenu.deleteMany({
where: { menuId: oldActivityMenu.id }
});
// 再删除菜单
await prisma.menu.delete({
where: { id: oldActivityMenu.id }
});
console.log(' ✅ 已删除旧的赛事活动菜单');
} else {
console.log(' ⏭️ 旧菜单不存在,跳过');
}
// 2. 更新独立的赛事活动菜单(改为父菜单,不需要 component
console.log('\n📝 步骤2: 更新赛事活动为父级菜单');
let activityParent = await prisma.menu.findFirst({
where: { path: '/student-activities' }
});
if (activityParent) {
await prisma.menu.update({
where: { id: activityParent.id },
data: {
component: null, // 父菜单不需要组件
}
});
console.log(' ✅ 已更新为父级菜单');
} else {
// 如果不存在,创建它
activityParent = await prisma.menu.create({
data: {
name: '赛事活动',
path: '/student-activities',
component: null,
icon: 'TrophyOutlined',
permission: 'contest:activity:read',
sort: 10,
parentId: null,
validState: 1,
}
});
console.log(' ✅ 已创建父级菜单');
}
// 3. 创建三个子菜单
console.log('\n📝 步骤3: 创建子菜单');
const subMenus = [
{
name: '我的指导',
path: '/student-activities/guidance',
component: 'activities/Guidance',
permission: 'contest:activity:read',
sort: 1,
},
{
name: '评审作品',
path: '/student-activities/review',
component: 'activities/Review',
permission: 'contest:activity:read',
sort: 2,
},
{
name: '预设评语',
path: '/student-activities/comments',
component: 'activities/Comments',
permission: 'contest:activity:read',
sort: 3,
},
];
for (const menuData of subMenus) {
const existing = await prisma.menu.findFirst({
where: { path: menuData.path }
});
if (existing) {
console.log(` ⏭️ ${menuData.name} 已存在`);
} else {
await prisma.menu.create({
data: {
...menuData,
parentId: activityParent.id,
validState: 1,
}
});
console.log(` ✅ 已创建: ${menuData.name}`);
}
}
// 4. 为租户分配新菜单
console.log('\n📝 步骤4: 分配菜单给租户');
const tenants = await prisma.tenant.findMany({
select: { id: true, name: true }
});
const allMenus = await prisma.menu.findMany({
where: {
OR: [
{ id: activityParent.id },
{ parentId: activityParent.id }
]
}
});
for (const tenant of tenants) {
for (const menu of allMenus) {
const existing = await prisma.tenantMenu.findFirst({
where: { tenantId: tenant.id, menuId: menu.id }
});
if (!existing) {
await prisma.tenantMenu.create({
data: { tenantId: tenant.id, menuId: menu.id }
});
}
}
console.log(` ✅ 已分配给租户: ${tenant.name}`);
}
// 5. 显示最终菜单结构
console.log('\n📋 最终菜单结构:');
const finalMenus = await prisma.menu.findMany({
where: {
OR: [
{ id: activityParent.id },
{ parentId: activityParent.id }
]
},
orderBy: { sort: 'asc' }
});
finalMenus.forEach(m => {
const prefix = m.parentId ? ' ' : '';
console.log(`${prefix}- ${m.name} (${m.path})`);
});
console.log('\n✅ 菜单更新完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,120 +0,0 @@
// 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);
}
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function updatePassword() {
try {
const tenantCode = 'super';
const username = 'admin';
const newPassword = process.argv[2] || 'cms@admin'; // 支持命令行参数传入新密码
console.log(`🔐 开始修改租户 "${tenantCode}" 的 admin 用户密码...\n`);
// 1. 查找租户
console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`);
const tenant = await prisma.tenant.findUnique({
where: { code: tenantCode },
});
if (!tenant) {
console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`);
process.exit(1);
}
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
// 2. 查找用户
console.log(`👤 步骤 2: 查找用户 "${username}"...`);
const existingUser = await prisma.user.findFirst({
where: {
tenantId: tenant.id,
username: username,
},
});
if (!existingUser) {
console.error(
`❌ 错误: 租户 "${tenantCode}" 下不存在用户 "${username}"`,
);
console.error(` 请先创建该用户`);
process.exit(1);
}
console.log(
`✅ 找到用户: ${existingUser.username} (${existingUser.nickname})\n`,
);
// 3. 加密新密码
console.log(`🔒 步骤 3: 加密新密码...`);
const hashedPassword = await bcrypt.hash(newPassword, 10);
console.log(`✅ 密码加密完成\n`);
// 4. 更新密码
console.log(`💾 步骤 4: 更新用户密码...`);
const updatedUser = await prisma.user.update({
where: { id: existingUser.id },
data: {
password: hashedPassword,
},
});
console.log(`✅ 密码修改成功!\n`);
console.log(`📊 更新结果:`);
console.log(` 租户名称: ${tenant.name}`);
console.log(` 租户编码: ${tenant.code}`);
console.log(` 用户ID: ${updatedUser.id}`);
console.log(` 用户名: ${updatedUser.username}`);
console.log(` 昵称: ${updatedUser.nickname}`);
console.log(` 新密码: ${newPassword}`);
console.log(` 修改时间: ${updatedUser.modifyTime}\n`);
} catch (error) {
console.error('❌ 修改密码时发生错误:');
console.error(error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
// 执行脚本
updatePassword()
.then(() => {
console.log('🎉 密码修改脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 密码修改脚本执行失败:', error);
process.exit(1);
});

View File

@ -1,115 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔧 更新学生角色权限...\n');
// 获取学生角色
const studentRole = await prisma.role.findFirst({
where: { code: 'student' }
});
if (!studentRole) {
console.log('❌ 未找到学生角色');
return;
}
console.log(`找到学生角色: ${studentRole.name} (ID: ${studentRole.id})`);
// 需要移除的权限
const permissionsToRemove = [
'contest:read', // 赛事列表权限
'contest:work:read', // 作品管理权限
'contest:work:update', // 作品更新权限
'work:submit', // 提交作品权限
'homework:update', // 作业更新权限
'homework:delete', // 作业删除权限
];
// 需要保留/添加的权限
const permissionsToKeep = [
'workbench:read', // 工作台
'contest:activity:read', // 赛事活动(新权限)
'homework:read', // 查看作业
];
// 移除不需要的权限
console.log('\n📝 移除不需要的权限...');
for (const permCode of permissionsToRemove) {
const perm = await prisma.permission.findFirst({
where: { tenantId: studentRole.tenantId, code: permCode }
});
if (perm) {
const deleted = await prisma.rolePermission.deleteMany({
where: { roleId: studentRole.id, permissionId: perm.id }
});
if (deleted.count > 0) {
console.log(` ✅ 已移除: ${permCode}`);
} else {
console.log(` ⏭️ ${permCode} 未分配,跳过`);
}
}
}
// 创建赛事活动权限(如果不存在)
console.log('\n📝 创建/分配赛事活动权限...');
let activityPerm = await prisma.permission.findFirst({
where: { tenantId: studentRole.tenantId, code: 'contest:activity:read' }
});
if (!activityPerm) {
activityPerm = await prisma.permission.create({
data: {
tenantId: studentRole.tenantId,
code: 'contest:activity:read',
name: '查看赛事活动',
resource: 'contest:activity',
action: 'read',
description: '学生查看赛事活动列表',
}
});
console.log(' ✅ 创建权限: contest:activity:read');
}
// 分配赛事活动权限给学生
const existingActivityPerm = await prisma.rolePermission.findFirst({
where: { roleId: studentRole.id, permissionId: activityPerm.id }
});
if (!existingActivityPerm) {
await prisma.rolePermission.create({
data: { roleId: studentRole.id, permissionId: activityPerm.id }
});
console.log(' ✅ 已分配给学生角色: contest:activity:read');
}
// 更新赛事活动菜单的权限
console.log('\n📝 更新菜单权限配置...');
const activityMenu = await prisma.menu.findFirst({
where: { name: '赛事活动' }
});
if (activityMenu) {
await prisma.menu.update({
where: { id: activityMenu.id },
data: { permission: 'contest:activity:read' }
});
console.log(' ✅ 赛事活动菜单权限更新为: contest:activity:read');
}
// 显示最终的学生权限
console.log('\n📋 学生角色最终权限:');
const finalPerms = await prisma.rolePermission.findMany({
where: { roleId: studentRole.id },
include: { permission: true }
});
finalPerms.forEach(rp => {
console.log(` - ${rp.permission.code}: ${rp.permission.name}`);
});
console.log('\n✅ 学生权限更新完成!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,78 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import * as dotenv from 'dotenv';
import * as path from 'path';
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 not found');
process.exit(1);
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function updateTenantMenuPermission() {
try {
console.log('🚀 开始更新租户管理菜单权限...\n');
// 查找租户管理菜单
const tenantMenu = await prisma.menu.findFirst({
where: {
name: '租户管理',
path: '/system/tenants',
},
});
if (!tenantMenu) {
console.log('❌ 租户管理菜单不存在');
return;
}
console.log(`找到租户管理菜单: ID=${tenantMenu.id}, 当前权限=${tenantMenu.permission}`);
if (tenantMenu.permission === 'tenant:update') {
console.log('✅ 菜单权限已经是 tenant:update无需更新');
return;
}
// 更新菜单权限
await prisma.menu.update({
where: { id: tenantMenu.id },
data: {
permission: 'tenant:update',
},
});
console.log('✅ 菜单权限已更新为 tenant:update');
console.log('\n说明:');
console.log(' - 普通租户只有 tenant:read 权限,可以读取租户列表(用于发布赛事选择公开范围)');
console.log(' - 只有超级租户才有 tenant:update 权限,才能看到租户管理菜单');
} catch (error) {
console.error('❌ 更新失败:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
updateTenantMenuPermission()
.then(() => {
console.log('\n🎉 脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 脚本执行失败:', error);
process.exit(1);
});

View File

@ -17,6 +17,11 @@ export class ContestsService {
*
*/
private isContestVisibleToTenant(contest: any, tenantId: number): boolean {
// 如果赛事未发布,对租户不可见
if (contest.contestState !== 'published') {
return false;
}
// 如果contestTenants为null表示所有租户可见
if (!contest.contestTenants) {
return true;
@ -173,6 +178,7 @@ export class ContestsService {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},
@ -232,6 +238,7 @@ export class ContestsService {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},
@ -239,28 +246,60 @@ export class ContestsService {
this.prisma.contest.count({ where }),
]);
// 如果指定了租户ID进行应用层过滤
// 如果指定了租户ID进行应用层过滤(超级租户不过滤,可以看到所有赛事)
let filteredList = allList;
let filteredTotal = allTotal;
if (tenantId) {
filteredList = allList.filter((contest) =>
this.isContestVisibleToTenant(contest, tenantId),
);
// 重新计算总数简化处理实际应该用原生SQL
filteredTotal = filteredList.length;
// 限制返回数量
filteredList = filteredList.slice(0, pageSize);
// 检查是否为超级租户
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
select: { isSuper: true },
});
const isSuperTenant = tenant?.isSuper === 1;
// 超级租户可以看到所有赛事,普通租户只能看到已发布且在公开范围内的赛事
if (!isSuperTenant) {
filteredList = allList.filter((contest) =>
this.isContestVisibleToTenant(contest, tenantId),
);
// 重新计算总数简化处理实际应该用原生SQL
filteredTotal = filteredList.length;
// 限制返回数量
filteredList = filteredList.slice(0, pageSize);
}
}
// 解析 contestTenants JSON 字符串为数组
const parsedList = filteredList.map((contest) => ({
...contest,
contestTenants: this.parseContestTenants(contest.contestTenants),
}));
return {
list: filteredList,
list: parsedList,
total: filteredTotal,
page,
pageSize,
};
}
/**
* contestTenants JSON
*/
private parseContestTenants(contestTenants: any): number[] | null {
if (!contestTenants) {
return null;
}
try {
return Array.isArray(contestTenants)
? contestTenants
: JSON.parse(contestTenants as string);
} catch {
return null;
}
}
/**
*
* @param role student-, teacher-, judge-
@ -389,6 +428,7 @@ export class ContestsService {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},
@ -437,12 +477,24 @@ export class ContestsService {
throw new NotFoundException('比赛不存在');
}
// 租户过滤:检查比赛是否对租户可见
if (tenantId && !this.isContestVisibleToTenant(contest, tenantId)) {
throw new NotFoundException('比赛不存在或无权访问');
// 租户过滤:检查比赛是否对租户可见(超级租户可以查看所有赛事)
if (tenantId) {
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
select: { isSuper: true },
});
const isSuperTenant = tenant?.isSuper === 1;
if (!isSuperTenant && !this.isContestVisibleToTenant(contest, tenantId)) {
throw new NotFoundException('比赛不存在或无权访问');
}
}
return contest;
// 解析 contestTenants JSON 字符串为数组
return {
...contest,
contestTenants: this.parseContestTenants(contest.contestTenants),
};
}
async update(
@ -655,6 +707,7 @@ export class ContestsService {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},
@ -779,6 +832,7 @@ export class ContestsService {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},
@ -822,6 +876,7 @@ export class ContestsService {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},

View File

@ -40,5 +40,13 @@ export class QueryWorkDto {
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
workNo?: string;
@IsString()
@IsOptional()
username?: string;
}

View File

@ -172,6 +172,8 @@ export class WorksService {
registrationId,
status,
title,
workNo,
username,
} = queryDto;
const skip = (page - 1) * pageSize;
@ -202,6 +204,31 @@ export class WorksService {
};
}
if (workNo) {
where.workNo = {
contains: workNo,
};
}
if (username) {
where.OR = [
{
submitterAccountNo: {
contains: username,
},
},
{
registration: {
user: {
username: {
contains: username,
},
},
},
},
];
}
const [list, total] = await Promise.all([
this.prisma.contestWork.findMany({
where,
@ -215,6 +242,12 @@ export class WorksService {
select: {
id: true,
contestName: true,
reviewRuleId: true,
reviewRule: {
select: {
judgeCount: true,
},
},
},
},
registration: {
@ -226,9 +259,33 @@ export class WorksService {
nickname: true,
},
},
team: {
select: {
id: true,
teamName: true,
},
},
},
},
attachments: true,
scores: {
where: { validState: 1 },
select: {
id: true,
totalScore: true,
},
},
assignments: {
include: {
judge: {
select: {
id: true,
username: true,
nickname: true,
},
},
},
},
_count: {
select: {
scores: true,
@ -240,8 +297,32 @@ export class WorksService {
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;
// 计算平均分
let averageScore: number | null = null;
if (work.scores && work.scores.length > 0) {
const totalScore = work.scores.reduce(
(sum, score) => sum + Number(score.totalScore || 0),
0,
);
averageScore = totalScore / work.scores.length;
}
return {
...work,
reviewedCount,
totalJudgesCount,
averageScore,
};
});
return {
list,
list: enrichedList,
total,
page,
pageSize,

View File

@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { Gender, UserStatus } from '../../users/dto/create-user.dto';
export class CreateJudgeDto {
@ -8,8 +8,8 @@ export class CreateJudgeDto {
@IsEnum(Gender)
gender: Gender;
@IsInt()
tenantId: number;
@IsString()
organization: string;
@IsString()
phone: string;

View File

@ -13,10 +13,9 @@ export class QueryJudgeDto {
@Type(() => Number)
pageSize?: number = 10;
@IsInt()
@IsString()
@IsOptional()
@Type(() => Number)
tenantId?: number;
organization?: string;
@IsString()
@IsOptional()

View File

@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { Gender, UserStatus } from '../../users/dto/create-user.dto';
export class UpdateJudgeDto {
@ -10,9 +10,9 @@ export class UpdateJudgeDto {
@IsOptional()
gender?: Gender;
@IsInt()
@IsString()
@IsOptional()
tenantId?: number;
organization?: string;
@IsString()
@IsOptional()

View File

@ -15,6 +15,24 @@ const JUDGE_ROLE_CODE = 'judge';
export class JudgesManagementService {
constructor(private prisma: PrismaService) {}
/**
* ID
*/
private async getSuperTenantId(): Promise<number> {
const superTenant = await this.prisma.tenant.findFirst({
where: {
isSuper: 1,
validState: 1,
},
});
if (!superTenant) {
throw new BadRequestException('系统未配置超级租户');
}
return superTenant.id;
}
/**
*
*/
@ -44,10 +62,13 @@ export class JudgesManagementService {
*
*/
async create(createJudgeDto: CreateJudgeDto, creatorId?: number) {
const { tenantId, password, ...userData } = createJudgeDto;
const { organization, password, ...userData } = createJudgeDto;
// 评委统一存储在超级租户下
const superTenantId = await this.getSuperTenantId();
// 确保评委角色存在
const judgeRoleId = await this.ensureJudgeRole(tenantId);
const judgeRoleId = await this.ensureJudgeRole(superTenantId);
// 生成用户名(如果没有提供)
let username = createJudgeDto.username;
@ -56,11 +77,11 @@ export class JudgesManagementService {
username = createJudgeDto.phone;
}
// 检查用户名是否已存在
// 检查用户名是否已存在(在超级租户下)
const existingUser = await this.prisma.user.findFirst({
where: {
username,
tenantId,
tenantId: superTenantId,
},
});
@ -76,7 +97,8 @@ export class JudgesManagementService {
data: {
...userData,
username,
tenantId,
tenantId: superTenantId,
organization, // 使用独立的所属单位字段
password: hashedPassword,
status: createJudgeDto.status || 'enabled',
creator: creatorId,
@ -87,12 +109,6 @@ export class JudgesManagementService {
},
},
include: {
tenant: {
select: {
id: true,
name: true,
},
},
roles: {
include: {
role: true,
@ -108,7 +124,7 @@ export class JudgesManagementService {
*
*/
async findAll(queryDto: QueryJudgeDto) {
const { page = 1, pageSize = 10, tenantId, nickname, username, status } = queryDto;
const { page = 1, pageSize = 10, organization, nickname, username, status } = queryDto;
const skip = (page - 1) * pageSize;
// 构建查询条件:必须有评委角色
@ -123,8 +139,10 @@ export class JudgesManagementService {
},
};
if (tenantId) {
where.tenantId = tenantId;
if (organization) {
where.organization = {
contains: organization,
};
}
if (nickname) {
@ -149,12 +167,6 @@ export class JudgesManagementService {
skip,
take: pageSize,
include: {
tenant: {
select: {
id: true,
name: true,
},
},
roles: {
include: {
role: true,
@ -210,12 +222,6 @@ export class JudgesManagementService {
},
},
include: {
tenant: {
select: {
id: true,
name: true,
},
},
roles: {
include: {
role: true,
@ -264,19 +270,10 @@ export class JudgesManagementService {
data.modifier = modifierId;
}
// 移除 tenantId不允许修改租户
delete data.tenantId;
return this.prisma.user.update({
where: { id },
data,
include: {
tenant: {
select: {
id: true,
name: true,
},
},
roles: {
include: {
role: true,
@ -329,14 +326,6 @@ export class JudgesManagementService {
data: {
status,
},
include: {
tenant: {
select: {
id: true,
name: true,
},
},
},
});
}

View File

@ -71,30 +71,7 @@ export class MenusService {
* @returns
*/
async findUserMenus(userId: number, tenantId: number) {
// 检查是否为超级租户
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
select: { isSuper: true },
});
const isSuperTenant = tenant?.isSuper === 1;
// 获取用户信息和角色
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: {
include: {
role: true,
},
},
},
});
// 检查用户是否有超级管理员角色
const userRoles = user?.roles?.map((ur: any) => ur.role.code) || [];
const isSuperAdmin = userRoles.includes('super_admin');
// 获取用户的所有权限(即使是超级租户,普通用户也需要权限检查)
// 获取用户的所有权限
const userPermissions = await this.authService.getUserPermissions(userId);
// 获取租户分配的菜单ID
@ -135,19 +112,10 @@ export class MenusService {
const menuTree = buildTree(allMenus);
// 过滤菜单:只有用户拥有菜单对应权限的菜单才会显示
// 超级管理员显示所有菜单,普通用户根据权限过滤
// 所有用户根据权限过滤菜单
const filterMenus = (menus: any[]): any[] => {
return menus
.map((menu) => {
// 超级管理员显示所有菜单
if (isSuperAdmin) {
const filtered = { ...menu };
if (menu.children && menu.children.length > 0) {
filtered.children = filterMenus(menu.children);
}
return filtered;
}
// 先递归处理子菜单
let filteredChildren: any[] = [];
if (menu.children && menu.children.length > 0) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -329,10 +329,24 @@ export interface ContestWork {
contest?: Contest;
registration?: ContestRegistration;
attachments?: ContestWorkAttachment[];
assignments?: Array<{
id: number;
judgeId: number;
status: string;
judge?: {
id: number;
username: string;
nickname: string;
};
}>;
_count?: {
scores: number;
assignments: number;
};
// 评审统计字段(由后端计算返回)
reviewedCount?: number;
totalJudgesCount?: number;
averageScore?: number | null;
}
export interface ContestWorkAttachment {
@ -365,6 +379,8 @@ export interface QueryWorkParams extends PaginationParams {
registrationId?: number;
status?: "submitted" | "locked" | "reviewing" | "rejected" | "accepted";
title?: string;
workNo?: string;
username?: string;
}
// ==================== 评审相关类型 ====================
@ -1063,6 +1079,17 @@ export const reviewsApi = {
>(`/contests/reviews/work/${workId}/final-score`);
return response;
},
// 替换评委
replaceJudge: async (
assignmentId: number,
newJudgeId: number
): Promise<void> => {
await request.post<any, void>(
`/contests/reviews/replace-judge`,
{ assignmentId, newJudgeId }
);
},
};
// 公告管理

View File

@ -10,16 +10,12 @@ export interface Judge {
gender?: 'male' | 'female';
avatar?: string;
status?: 'enabled' | 'disabled';
tenantId?: number;
organization?: string;
validState?: number;
creator?: number;
modifier?: number;
createTime?: string;
modifyTime?: string;
tenant?: {
id: number;
name: string;
};
roles?: Array<{
id: number;
role: {
@ -39,7 +35,7 @@ export interface Judge {
}
export interface QueryJudgeParams extends PaginationParams {
tenantId?: number;
organization?: string;
nickname?: string;
username?: string;
status?: 'enabled' | 'disabled';
@ -48,7 +44,7 @@ export interface QueryJudgeParams extends PaginationParams {
export interface CreateJudgeForm {
nickname: string;
gender: 'male' | 'female';
tenantId: number;
organization: string;
phone: string;
password: string;
username?: string;
@ -60,6 +56,7 @@ export interface CreateJudgeForm {
export interface UpdateJudgeForm {
nickname?: string;
gender?: 'male' | 'female';
organization?: string;
phone?: string;
password?: string;
username?: string;
@ -153,3 +150,4 @@ export const judgesManagementApi = {
};

View File

@ -110,6 +110,28 @@ const baseRoutes: RouteRecordRaw[] = [
permissions: ["contest:registration:read"],
},
},
// 评审进度详情路由
{
path: "contests/reviews/:id/progress",
name: "ReviewProgressDetail",
component: () => import("@/views/contests/reviews/ProgressDetail.vue"),
meta: {
title: "评审进度详情",
requiresAuth: true,
permissions: ["review:read"],
},
},
// 参赛作品详情列表路由
{
path: "contests/works/:id/list",
name: "WorksDetail",
component: () => import("@/views/contests/works/WorksDetail.vue"),
meta: {
title: "参赛作品详情",
requiresAuth: true,
permissions: ["work:read"],
},
},
// 作业提交记录路由
{
path: "homework/submissions",

View File

@ -30,9 +30,14 @@ const componentMap: Record<string, () => Promise<any>> = {
"contests/registrations/Index": () =>
import("@/views/contests/registrations/Index.vue"),
"contests/works/Index": () => import("@/views/contests/works/Index.vue"),
"contests/works/WorksDetail": () =>
import("@/views/contests/works/WorksDetail.vue"),
"contests/reviews/Index": () => import("@/views/contests/reviews/Index.vue"),
"contests/reviews/Tasks": () => import("@/views/contests/reviews/Tasks.vue"),
"contests/reviews/Progress": () =>
import("@/views/contests/reviews/Progress.vue"),
"contests/reviews/ProgressDetail": () =>
import("@/views/contests/reviews/ProgressDetail.vue"),
"contests/judges/Index": () => import("@/views/contests/judges/Index.vue"),
"contests/results/Index": () => import("@/views/contests/results/Index.vue"),
"contests/notices/Index": () => import("@/views/contests/notices/Index.vue"),

View File

@ -1,7 +1,7 @@
<template>
<div class="contests-page">
<a-card class="mb-4">
<template #title>比赛管理</template>
<template #title>赛事列表</template>
<template #extra>
<a-button
v-permission="'contest:create'"
@ -21,15 +21,15 @@
class="search-form"
@finish="handleSearch"
>
<a-form-item label="赛名称">
<a-form-item label="名称">
<a-input
v-model:value="searchParams.contestName"
placeholder="请输入赛名称"
placeholder="请输入名称"
allow-clear
style="width: 200px"
/>
</a-form-item>
<a-form-item label="发布状态">
<a-form-item label="赛事状态">
<a-select
v-model:value="searchParams.contestState"
placeholder="请选择状态"
@ -40,23 +40,12 @@
<a-select-option value="unpublished">未发布</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="赛事状态">
<a-select
v-model:value="searchParams.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="ongoing">进行中</a-select-option>
<a-select-option value="finished">已完结</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="比赛类型">
<a-form-item label="赛事类型">
<a-select
v-model:value="searchParams.contestType"
placeholder="请选择类型"
allow-clear
style="width: 150px"
style="width: 120px"
>
<a-select-option value="individual">个人赛</a-select-option>
<a-select-option value="team">团队赛</a-select-option>
@ -83,9 +72,9 @@
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'contestName'">
{{ record.contestName }}
<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 === 'contestType'">
<a-tag
@ -101,16 +90,33 @@
{{ record.contestState === "published" ? "已发布" : "未发布" }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'ongoing' ? 'processing' : 'orange'">
{{ record.status === "ongoing" ? "进行中" : "已完结" }}
</a-tag>
<template v-else-if="column.key === 'publicScope'">
<template
v-if="record.contestTenants && record.contestTenants.length > 0"
>
<a-tooltip>
<template #title>
<div v-for="tenantId in record.contestTenants" :key="tenantId">
{{ getTenantName(tenantId) }}
</div>
</template>
<a-tag>{{ record.contestTenants.length }}个机构</a-tag>
</a-tooltip>
</template>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'timeRange'">
<div>
<div>开始{{ formatDateTime(record.startTime) }}</div>
<div>结束{{ formatDateTime(record.endTime) }}</div>
<template v-else-if="column.key === 'judges'">
<a-tag v-if="record._count?.judges > 0" color="blue">
{{ record._count.judges }}
</a-tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'contestTime'">
<div v-if="record.startTime || record.endTime">
<div>{{ formatDate(record.startTime) }}</div>
<div> {{ formatDate(record.endTime) }}</div>
</div>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
@ -119,7 +125,7 @@
v-permission="'contest:publish'"
type="link"
size="small"
@click="handlePublish(record)"
@click="handlePublishClick(record)"
>
{{ record.contestState === "published" ? "取消发布" : "发布" }}
</a-button>
@ -143,13 +149,15 @@
编辑
</a-button>
<!-- 删除 -->
<a-popconfirm
<a-button
v-permission="'contest:delete'"
title="确定要删除这个比赛吗?"
@confirm="handleDelete(record.id)"
type="link"
danger
size="small"
@click="handleDeleteClick(record)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
删除
</a-button>
</a-space>
</template>
</template>
@ -171,12 +179,70 @@
@success="handleJudgeAddSuccess"
/>
</a-drawer>
<!-- 发布弹框 -->
<a-modal
v-model:open="publishModalVisible"
title="发布赛事"
:confirm-loading="publishLoading"
@ok="handlePublishConfirm"
>
<a-form layout="vertical">
<a-form-item label="选择公开范围(可见机构)" required>
<a-select
v-model:value="selectedTenants"
mode="multiple"
placeholder="请选择公开范围"
style="width: 100%"
:options="tenantOptions"
:filter-option="filterTenantOption"
show-search
/>
</a-form-item>
</a-form>
<a-alert
type="warning"
message="发布后,只有选中的机构可以看到此赛事"
show-icon
class="mt-2"
/>
</a-modal>
<!-- 取消发布确认弹框 -->
<a-modal
v-model:open="unpublishModalVisible"
title="取消发布"
:confirm-loading="publishLoading"
@ok="handleUnpublishConfirm"
>
<p>确定要取消发布赛事{{ currentPublishContest?.contestName }}</p>
<a-alert
type="warning"
message="取消发布后,所有机构将无法看到此赛事"
show-icon
/>
</a-modal>
<!-- 删除确认弹框 -->
<a-modal
v-model:open="deleteModalVisible"
title="删除赛事"
:confirm-loading="deleteLoading"
@ok="handleDeleteConfirm"
>
<p>确定要删除赛事{{ currentDeleteContest?.contestName }}</p>
<a-alert
type="error"
message="删除后数据将无法恢复,请谨慎操作!"
show-icon
/>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from "vue-router"
import { ref } from "vue"
import { ref, onMounted } from "vue"
import { message } from "ant-design-vue"
import {
PlusOutlined,
@ -189,6 +255,7 @@ import {
type Contest,
type QueryContestParams,
} from "@/api/contests"
import { tenantsApi, type Tenant } from "@/api/tenants"
import AddJudgeDrawer from "./components/AddJudgeDrawer.vue"
import dayjs from "dayjs"
@ -210,73 +277,61 @@ const {
requestFn: contestsApi.getList,
defaultSearchParams: {} as QueryContestParams,
defaultPageSize: 10,
errorMessage: "获取赛列表失败",
errorMessage: "获取列表失败",
})
//
const tenants = ref<Tenant[]>([])
const tenantOptions = ref<{ label: string; value: number }[]>([])
//
const columns = [
{ title: "序号", key: "index", width: 70 },
{
title: "比赛名称",
key: "contestName",
title: "赛事名称",
dataIndex: "contestName",
key: "contestName",
width: 200,
},
{
title: "比赛类型",
key: "contestType",
dataIndex: "contestType",
width: 100,
},
{
title: "发布状态",
key: "contestState",
dataIndex: "contestState",
width: 90,
},
{
title: "赛事状态",
key: "status",
dataIndex: "status",
width: 90,
},
{
title: "时间范围",
key: "timeRange",
width: 250,
},
{
title: "报名时间",
key: "registerTime",
width: 250,
customRender: ({ record }: { record: Contest }) => {
return `${formatDateTime(record.registerStartTime)} - ${formatDateTime(
record.registerEndTime
)}`
},
},
{
title: "统计",
key: "statistics",
width: 150,
customRender: ({ record }: { record: Contest }) => {
const count = record._count || { registrations: 0, works: 0 }
return `报名: ${count.registrations || 0} | 作品: ${count.works || 0}`
},
},
{
title: "操作",
key: "action",
width: 320,
fixed: "right" as const,
},
{ title: "赛事类型", key: "contestType", width: 100 },
{ title: "赛事状态", key: "contestState", width: 100 },
{ title: "公开范围", key: "publicScope", width: 120 },
{ title: "评委", key: "judges", width: 80 },
{ title: "赛事时间", key: "contestTime", width: 180 },
{ title: "操作", key: "action", width: 260, fixed: "right" as const },
]
//
const formatDateTime = (dateStr?: string) => {
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const getTenantName = (tenantId: number) => {
const tenant = tenants.value.find((t) => t.id === tenantId)
return tenant?.name || `机构${tenantId}`
}
//
const filterTenantOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
//
const fetchTenants = async () => {
try {
const response = await tenantsApi.getList({ page: 1, pageSize: 100 })
tenants.value = response.list
tenantOptions.value = response.list.map((t) => ({
label: t.name,
value: t.id,
}))
} catch (error) {
console.error("获取租户列表失败", error)
}
}
//
const handleSearch = () => {
search()
@ -295,7 +350,7 @@ const handleAdd = () => {
//
const handleEdit = (id: number) => {
if (!id) {
message.warning("赛ID不存在")
message.warning("ID不存在")
return
}
const path = `/${tenantCode}/contests/${id}/edit`
@ -313,13 +368,12 @@ const currentContest = ref<Contest | null>(null)
//
const handleAddJudge = async (id: number) => {
currentContestId.value = id
//
try {
const contest = await contestsApi.getDetail(id)
currentContest.value = contest
judgeDrawerVisible.value = true
} catch (error: any) {
message.error(error?.response?.data?.message || "获取赛信息失败")
message.error(error?.response?.data?.message || "获取信息失败")
}
}
@ -333,37 +387,130 @@ const handleJudgeDrawerClose = () => {
//
const handleJudgeAddSuccess = () => {
message.success("添加评委成功")
fetchList() //
fetchList()
handleJudgeDrawerClose()
}
// /
const handlePublish = async (record: Contest) => {
try {
const newState =
record.contestState === "published" ? "unpublished" : "published"
await contestsApi.publish(record.id, newState)
message.success(newState === "published" ? "发布成功" : "撤回成功")
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
//
const publishModalVisible = ref(false)
const unpublishModalVisible = ref(false)
const publishLoading = ref(false)
const currentPublishContest = ref<Contest | null>(null)
const selectedTenants = ref<number[]>([])
// /
const handlePublishClick = async (record: Contest) => {
currentPublishContest.value = record
if (record.contestState === "published") {
//
unpublishModalVisible.value = true
} else {
// -
// contestTenants
try {
const contest = await contestsApi.getDetail(record.id)
// contestTenants nullundefined
if (
Array.isArray(contest.contestTenants) &&
contest.contestTenants.length > 0
) {
selectedTenants.value = contest.contestTenants
.map((id) => Number(id))
.filter((id) => !isNaN(id))
} else {
selectedTenants.value = []
}
publishModalVisible.value = true
} catch (error: any) {
message.error(error?.response?.data?.message || "获取赛事信息失败")
}
}
}
//
const handleDelete = async (id: number) => {
//
const handlePublishConfirm = async () => {
if (selectedTenants.value.length === 0) {
message.warning("请至少选择一个公开范围")
return
}
publishLoading.value = true
try {
await contestsApi.delete(id)
//
await contestsApi.update(currentPublishContest.value!.id, {
contestTenants: selectedTenants.value,
})
//
await contestsApi.publish(currentPublishContest.value!.id, "published")
message.success("发布成功")
publishModalVisible.value = false
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "发布失败")
} finally {
publishLoading.value = false
}
}
//
const handleUnpublishConfirm = async () => {
publishLoading.value = true
try {
await contestsApi.publish(currentPublishContest.value!.id, "unpublished")
message.success("取消发布成功")
unpublishModalVisible.value = false
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "取消发布失败")
} finally {
publishLoading.value = false
}
}
//
const deleteModalVisible = ref(false)
const deleteLoading = ref(false)
const currentDeleteContest = ref<Contest | null>(null)
//
const handleDeleteClick = (record: Contest) => {
currentDeleteContest.value = record
deleteModalVisible.value = true
}
//
const handleDeleteConfirm = async () => {
deleteLoading.value = true
try {
await contestsApi.delete(currentDeleteContest.value!.id)
message.success("删除成功")
deleteModalVisible.value = false
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "删除失败")
} finally {
deleteLoading.value = false
}
}
onMounted(() => {
fetchTenants()
})
</script>
<style scoped>
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
.mt-2 {
margin-top: 8px;
}
</style>

View File

@ -12,22 +12,14 @@
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item v-if="hasTenantReadPermission" label="所属单位">
<a-select
v-model:value="searchParams.tenantId"
placeholder="请选择所属单位"
<a-form-item label="所属单位">
<a-input
v-model:value="searchParams.organization"
placeholder="请输入所属单位"
allow-clear
style="width: 200px"
:loading="tenantsLoading"
>
<a-select-option
v-for="tenant in tenantsList"
:key="tenant.id"
:value="tenant.id"
>
{{ tenant.name }}
</a-select-option>
</a-select>
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
@ -62,8 +54,8 @@
@change="handleJudgeTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tenant'">
{{ record.tenant?.name || "-" }}
<template v-if="column.key === 'organization'">
{{ record.organization || "-" }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'enabled' ? 'success' : 'error'">
@ -77,7 +69,7 @@
<!-- 已选评委区域 -->
<a-card size="small">
<template #title>
已选/{{ judgeCount || 0 }}
已选 {{ selectedJudges.length }} / {{ judgeCount || 0 }}
<span
v-if="selectedJudges.length > (judgeCount || 0)"
class="warning-text"
@ -97,7 +89,7 @@
{{ item.nickname }}{{ item.username }}
</template>
<template #description>
{{ item.tenant?.name || "-" }}
{{ item.organization || "-" }}
</template>
</a-list-item-meta>
<template #actions>
@ -141,9 +133,7 @@ import { message } from "ant-design-vue"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import type { Judge, QueryJudgeParams } from "@/api/judges-management"
import { judgesManagementApi } from "@/api/judges-management"
import { judgesApi, type ContestJudge } from "@/api/contests"
import { tenantsApi, type Tenant } from "@/api/tenants"
import { useAuthStore } from "@/stores/auth"
import { judgesApi } from "@/api/contests"
import type { Contest } from "@/api/contests"
interface Props {
@ -156,24 +146,17 @@ const emit = defineEmits<{
success: []
}>()
const authStore = useAuthStore()
//
const judgeCount = computed(() => {
return props.contest.reviewRule?.judgeCount || 0
})
//
const hasTenantReadPermission = computed(() => {
return authStore.hasPermission("tenant:read")
})
//
const searchParams = reactive<QueryJudgeParams>({
page: 1,
pageSize: 10,
nickname: undefined,
tenantId: undefined,
organization: undefined,
})
//
@ -192,10 +175,6 @@ const selectedJudgeIds = ref<number[]>([])
const selectedJudges = ref<Judge[]>([])
const selectedJudgesLoading = ref(false)
//
const tenantsList = ref<Tenant[]>([])
const tenantsLoading = ref(false)
//
const submitLoading = ref(false)
@ -215,7 +194,8 @@ const judgeColumns = [
},
{
title: "所属单位",
key: "tenant",
dataIndex: "organization",
key: "organization",
width: 150,
},
{
@ -239,7 +219,7 @@ const loadJudges = async () => {
page: judgePagination.current,
pageSize: judgePagination.pageSize,
nickname: searchParams.nickname || undefined,
tenantId: searchParams.tenantId || undefined,
organization: searchParams.organization || undefined,
}
const res = await judgesManagementApi.getList(params)
judgeList.value = res.list
@ -276,34 +256,13 @@ const loadSelectedJudges = async () => {
}
}
//
const loadTenants = async () => {
if (!hasTenantReadPermission.value) {
return
}
tenantsLoading.value = true
try {
const res = await tenantsApi.getList({ page: 1, pageSize: 100 })
tenantsList.value = res.list
} catch (error: any) {
if (error?.response?.status !== 403) {
console.error("加载租户列表失败", error)
}
} finally {
tenantsLoading.value = false
}
}
//
const isJudgeSelected = (judgeId: number): boolean => {
return selectedJudgeIds.value.includes(judgeId)
}
//
const handleJudgeSelectionChange = (
selectedKeys: number[],
selectedRows: Judge[]
) => {
const handleJudgeSelectionChange = (selectedKeys: number[]) => {
// selectedKeys selectedJudgeIds
const newSelectedIds = selectedKeys.filter(
(id) => !selectedJudgeIds.value.includes(id)
@ -347,7 +306,7 @@ const handleSearch = () => {
//
const handleReset = () => {
searchParams.nickname = undefined
searchParams.tenantId = undefined
searchParams.organization = undefined
handleSearch()
}
@ -414,9 +373,9 @@ watch(
)
watch(
() => searchParams.tenantId,
() => searchParams.organization,
() => {
if (searchParams.tenantId === undefined) {
if (searchParams.organization === undefined || searchParams.organization === "") {
handleSearch()
}
}
@ -425,7 +384,6 @@ watch(
onMounted(() => {
loadJudges()
loadSelectedJudges()
loadTenants()
})
</script>

View File

@ -54,22 +54,13 @@
class="search-form"
@finish="handleSearch"
>
<a-form-item v-if="hasTenantReadPermission" label="所属单位">
<a-select
v-model:value="searchParams.tenantId"
placeholder="请选择所属单位"
<a-form-item label="所属单位">
<a-input
v-model:value="searchParams.organization"
placeholder="请输入所属单位"
allow-clear
style="width: 200px"
:loading="tenantsLoading"
>
<a-select-option
v-for="tenant in tenantsList"
:key="tenant.id"
:value="tenant.id"
>
{{ tenant.name }}
</a-select-option>
</a-select>
/>
</a-form-item>
<a-form-item label="姓名">
<a-input
@ -124,8 +115,8 @@
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'tenant'">
{{ record.tenant?.name || "-" }}
<template v-else-if="column.key === 'organization'">
{{ record.organization || "-" }}
</template>
<template v-else-if="column.key === 'gender'">
<span v-if="record.gender === 'male'"></span>
@ -220,54 +211,11 @@
<a-radio value="female"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
v-if="hasTenantReadPermission"
label="所属单位"
name="tenantId"
>
<a-select
v-model:value="form.tenantId"
placeholder="请选择所属单位"
:loading="tenantsLoading"
:disabled="isEditing"
show-search
:filter-option="(input: string, option: any) => {
const label = option?.children?.[0]?.children || option?.label || ''
return String(label).toLowerCase().includes(input.toLowerCase())
}"
>
<a-select-option
v-for="tenant in tenantsList"
:key="tenant.id"
:value="tenant.id"
>
{{ tenant.name }}
</a-select-option>
</a-select>
<div
v-if="
!isEditing &&
form.tenantId &&
form.tenantId !== authStore.user?.tenantId
"
class="tenant-warning"
>
<a-alert
message="注意"
description="选择的租户与当前租户不同,创建的用户将属于当前租户"
type="warning"
show-icon
:closable="false"
/>
</div>
</a-form-item>
<a-form-item v-else label="所属单位">
<a-form-item label="所属单位" name="organization">
<a-input
:value="
tenantsList.find((t) => t.id === authStore.user?.tenantId)
?.name || '当前租户'
"
disabled
v-model:value="form.organization"
placeholder="请输入所属单位"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="联系方式" name="phone">
@ -312,18 +260,15 @@ import {
} from "@ant-design/icons-vue"
import { useListRequest } from "@/composables/useListRequest"
import { contestsApi } from "@/api/contests"
import { tenantsApi, type Tenant } from "@/api/tenants"
import {
judgesManagementApi,
type Judge,
type QueryJudgeParams,
} from "@/api/judges-management"
import { useAuthStore } from "@/stores/auth"
const route = useRoute()
const contestId = route.params.id ? Number(route.params.id) : null
const tenantCode = route.params.tenantCode as string
const authStore = useAuthStore()
// contestId
const isValidContestId =
@ -352,10 +297,6 @@ const {
errorMessage: "获取评委列表失败",
})
//
const tenantsList = ref<Tenant[]>([])
const tenantsLoading = ref(false)
//
const contestName = ref("")
@ -379,14 +320,14 @@ const form = reactive<{
username: string
nickname: string
gender: "male" | "female" | undefined
tenantId: number | undefined
organization: string
phone: string
password: string
}>({
username: "",
nickname: "",
gender: undefined,
tenantId: undefined,
organization: "",
phone: "",
password: "",
})
@ -398,7 +339,7 @@ const rules = computed(() => ({
: [{ required: true, message: "请输入账号", trigger: "blur" }],
nickname: [{ required: true, message: "请输入姓名", trigger: "blur" }],
gender: [{ required: true, message: "请选择性别", trigger: "change" }],
tenantId: [{ required: true, message: "请选择所属单位", trigger: "change" }],
organization: [{ required: true, message: "请输入所属单位", trigger: "blur" }],
phone: [{ required: true, message: "请输入联系方式", trigger: "blur" }],
password: isEditing.value
? []
@ -414,7 +355,8 @@ const columns = [
},
{
title: "所属单位",
key: "tenant",
key: "organization",
dataIndex: "organization",
width: 150,
},
{
@ -458,42 +400,6 @@ const columns = [
},
]
//
const hasTenantReadPermission = computed(() => {
return authStore.hasPermission("tenant:read")
})
//
const loadTenants = async () => {
//
if (!hasTenantReadPermission.value) {
return
}
tenantsLoading.value = true
try {
// 使 pageSize 100
// 使""
const res = await tenantsApi.getList({ page: 1, pageSize: 100 })
tenantsList.value = res.list
// pageSize
if (res.list.length === 10000 && res.total > 10000) {
console.warn(`租户数量较多(${res.total})当前仅显示前10000个`)
}
} catch (error: any) {
// request.ts
if (error?.response?.status === 403) {
console.warn("缺少租户读取权限,租户筛选功能不可用")
} else {
console.error("加载租户列表失败", error)
message.error("加载租户列表失败,请稍后重试")
}
} finally {
tenantsLoading.value = false
}
}
//
const loadContestInfo = async () => {
// contestId
@ -526,15 +432,9 @@ const handleAdd = () => {
form.username = ""
form.nickname = ""
form.gender = undefined
// 使ID
form.tenantId = authStore.user?.tenantId
form.organization = ""
form.phone = ""
form.password = ""
//
if (hasTenantReadPermission.value && tenantsList.value.length === 0) {
loadTenants()
}
}
//
@ -545,7 +445,7 @@ const handleEdit = (record: Judge) => {
form.username = record.username || ""
form.nickname = record.nickname || ""
form.gender = record.gender as "male" | "female" | undefined
form.tenantId = record.tenantId
form.organization = record.organization || ""
form.phone = record.phone || ""
form.password = ""
}
@ -601,6 +501,7 @@ const handleSubmit = async () => {
await judgesManagementApi.update(editingId.value, {
nickname: form.nickname,
gender: form.gender,
organization: form.organization,
phone: form.phone,
...(form.password && { password: form.password }),
})
@ -611,7 +512,7 @@ const handleSubmit = async () => {
username: form.username,
nickname: form.nickname,
gender: form.gender!,
tenantId: form.tenantId!,
organization: form.organization,
phone: form.phone,
password: form.password,
status: "enabled",
@ -641,7 +542,6 @@ const handleCancel = () => {
}
onMounted(() => {
loadTenants()
loadContestInfo()
})
</script>
@ -650,8 +550,4 @@ onMounted(() => {
.search-form {
margin-bottom: 16px;
}
.tenant-warning {
margin-top: 8px;
}
</style>

View File

@ -1,645 +1,228 @@
<template>
<div class="review-progress-page">
<a-card>
<template #title>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/contests`">赛事管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>
<router-link :to="`/contests/${contestId}`">{{
progressData?.contest?.contestName || "赛事详情"
}}</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>评审进度</a-breadcrumb-item>
</a-breadcrumb>
</template>
<template #extra>
<a-space>
<a-button @click="fetchProgress" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button @click="$router.back()">返回</a-button>
</a-space>
</template>
<a-spin :spinning="loading">
<!-- 总体进度概览 -->
<a-row :gutter="16" class="stats-row">
<a-col :span="6">
<a-statistic
title="作品总数"
:value="progressData?.summary?.totalWorks || 0"
>
<template #suffix></template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
title="评委总数"
:value="progressData?.summary?.totalJudges || 0"
>
<template #suffix></template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
title="待分配作品"
:value="progressData?.summary?.unassignedWorksCount || 0"
:value-style="{
color:
(progressData?.summary?.unassignedWorksCount || 0) > 0
? '#cf1322'
: '#3f8600',
}"
>
<template #suffix></template>
</a-statistic>
</a-col>
<a-col :span="6">
<a-statistic
title="待评审数"
:value="progressData?.summary?.pendingScoresCount || 0"
:value-style="{
color:
(progressData?.summary?.pendingScoresCount || 0) > 0
? '#faad14'
: '#3f8600',
}"
>
<template #suffix></template>
</a-statistic>
</a-col>
</a-row>
<!-- 进度条 -->
<a-card title="评审进度" class="progress-card" size="small">
<a-row :gutter="[24, 16]">
<a-col :span="8">
<div class="progress-item">
<div class="progress-label">作品分配进度</div>
<a-progress
:percent="progressData?.progress?.assignmentProgress || 0"
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
/>
<div class="progress-desc">
{{ progressData?.summary?.assignedWorksCount || 0 }} /
{{ progressData?.summary?.totalWorks || 0 }} 件已分配
</div>
</div>
</a-col>
<a-col :span="8">
<div class="progress-item">
<div class="progress-label">评审完成进度</div>
<a-progress
:percent="progressData?.progress?.scoringProgress || 0"
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
/>
<div class="progress-desc">
{{ progressData?.summary?.totalScores || 0 }} /
{{ progressData?.summary?.totalAssignments || 0 }} 条已评分
</div>
</div>
</a-col>
<a-col :span="8">
<div class="progress-item">
<div class="progress-label">总体完成进度</div>
<a-progress
:percent="progressData?.progress?.overallProgress || 0"
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
/>
<div class="progress-desc">
{{ progressData?.summary?.scoredWorksCount || 0 }} /
{{ progressData?.summary?.totalWorks || 0 }} 件已评审
</div>
</div>
</a-col>
</a-row>
</a-card>
<!-- 评委进度表格 -->
<a-card title="评委工作进度" class="judge-progress-card" size="small">
<template #extra>
<a-space>
<a-button
v-permission="'review:assign'"
type="primary"
@click="handleAutoAssign"
:loading="autoAssignLoading"
>
<template #icon><ThunderboltOutlined /></template>
自动分配
</a-button>
<a-button
v-permission="'review:assign'"
@click="showBatchAssignModal = true"
>
<template #icon><PlusOutlined /></template>
批量分配
</a-button>
</a-space>
</template>
<a-table
:columns="judgeColumns"
:data-source="progressData?.judgeProgress || []"
:pagination="false"
row-key="judgeId"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
<a-space>
<a-avatar size="small">{{
(record.judgeName || "?")[0]
}}</a-avatar>
{{ record.judgeName || "-" }}
</a-space>
</template>
<template v-else-if="column.key === 'progress'">
<a-progress
:percent="record.progress"
size="small"
:status="record.progress === 100 ? 'success' : 'active'"
/>
</template>
<template v-else-if="column.key === 'pendingCount'">
<a-tag :color="record.pendingCount > 0 ? 'orange' : 'green'">
{{ record.pendingCount }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 待分配作品和待评审列表 -->
<a-row :gutter="16">
<a-col :span="12">
<a-card title="待分配作品" size="small" class="list-card">
<template #extra>
<a-tag color="red">{{
progressData?.summary?.unassignedWorksCount || 0
}}</a-tag>
</template>
<a-list
:data-source="progressData?.unassignedWorks || []"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="item.title"
:description="`编号: ${item.workNo || '-'}`"
>
<template #avatar>
<FileOutlined style="font-size: 20px; color: #52c41a" />
</template>
</a-list-item-meta>
<template #actions>
<a-button
type="link"
size="small"
@click="handleQuickAssign(item)"
>
分配
</a-button>
</template>
</a-list-item>
</template>
<template
#header
v-if="(progressData?.unassignedWorks?.length || 0) === 0"
>
<a-empty description="所有作品已分配" />
</template>
</a-list>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="待评审分配" size="small" class="list-card">
<template #extra>
<a-tag color="orange">{{
progressData?.summary?.pendingScoresCount || 0
}}</a-tag>
</template>
<a-list
:data-source="progressData?.pendingAssignments || []"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="item.workTitle"
:description="`评委: ${item.judgeName}`"
>
<template #avatar>
<ClockCircleOutlined
style="font-size: 20px; color: #faad14"
/>
</template>
</a-list-item-meta>
<template #actions>
<span class="assign-time">{{
formatDateTime(item.assignmentTime)
}}</span>
</template>
</a-list-item>
</template>
<template
#header
v-if="(progressData?.pendingAssignments?.length || 0) === 0"
>
<a-empty description="所有分配已评审完成" />
</template>
</a-list>
</a-card>
</a-col>
</a-row>
<!-- 评审时间信息 -->
<a-card title="评审时间" size="small" class="time-card">
<a-descriptions :column="3">
<a-descriptions-item label="评审开始时间">
{{ formatDateTime(progressData?.contest?.reviewStartTime) }}
</a-descriptions-item>
<a-descriptions-item label="评审结束时间">
{{ formatDateTime(progressData?.contest?.reviewEndTime) }}
</a-descriptions-item>
<a-descriptions-item label="评审状态">
<a-tag :color="getReviewStatusColor()">{{
getReviewStatusText()
}}</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-spin>
<a-card class="mb-4">
<template #title>评审进度</template>
</a-card>
<!-- 批量分配弹窗 -->
<a-modal
v-model:open="showBatchAssignModal"
title="批量分配作品"
:confirm-loading="batchAssignLoading"
width="700px"
@ok="handleBatchAssign"
@cancel="showBatchAssignModal = false"
>
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="选择作品" required>
<a-select
v-model:value="batchAssignForm.workIds"
mode="multiple"
placeholder="请选择要分配的作品"
style="width: 100%"
:max-tag-count="3"
>
<a-select-option
v-for="work in availableWorks"
:key="work.id"
:value="work.id"
>
{{ work.title }} ({{ work.workNo || "-" }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="选择评委" required>
<a-select
v-model:value="batchAssignForm.judgeIds"
mode="multiple"
placeholder="请选择评委"
style="width: 100%"
:max-tag-count="3"
>
<a-select-option
v-for="judge in judges"
:key="judge.judgeId"
:value="judge.judgeId"
>
{{ judge.judge?.nickname || judge.judge?.username }}
<span v-if="judge.specialty">({{ judge.specialty }})</span>
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- Tab栏 -->
<a-tabs v-model:activeKey="activeTab" class="contest-tabs" @change="handleTabChange">
<a-tab-pane key="individual" tab="个人赛" />
<a-tab-pane key="team" tab="团队赛" />
</a-tabs>
<!-- 快速分配弹窗 -->
<a-modal
v-model:open="showQuickAssignModal"
title="分配作品给评委"
:confirm-loading="quickAssignLoading"
width="500px"
@ok="handleQuickAssignSubmit"
@cancel="showQuickAssignModal = false"
<!-- 搜索表单 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="赛事名称">
<a-input
v-model:value="searchParams.contestName"
placeholder="请输入赛事名称"
allow-clear
style="width: 200px"
/>
</a-form-item>
<a-form-item label="评审状态">
<a-select
v-model:value="searchParams.reviewStatus"
placeholder="请选择评审状态"
allow-clear
style="width: 150px"
>
<a-select-option value="not_started">未开始</a-select-option>
<a-select-option value="in_progress">进行中</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
</a-select>
</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"
>
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-form-item label="作品">
<span
>{{ quickAssignWork?.title }} ({{
quickAssignWork?.workNo || "-"
}})</span
>
</a-form-item>
<a-form-item label="选择评委" required>
<a-select
v-model:value="quickAssignJudgeIds"
mode="multiple"
placeholder="请选择评委"
style="width: 100%"
>
<a-select-option
v-for="judge in judges"
:key="judge.judgeId"
:value="judge.judgeId"
>
{{ judge.judge?.nickname || judge.judge?.username }}
<span v-if="judge.specialty">({{ judge.specialty }})</span>
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<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 === 'reviewStatus'">
<a-tag :color="getReviewStatusColor(record)">
{{ getReviewStatusText(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'reviewedCount'">
{{ record.reviewedCount || 0 }} / {{ record.totalWorksCount || 0 }}
</template>
<template v-else-if="column.key === 'reviewTime'">
<div v-if="record.reviewStartTime || record.reviewEndTime">
<div>{{ formatDate(record.reviewStartTime) }}</div>
<div> {{ formatDate(record.reviewEndTime) }}</div>
</div>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">
查看详情
</a-button>
</template>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from "vue"
import { useRoute } from "vue-router"
import { ref, reactive, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import {
ReloadOutlined,
ThunderboltOutlined,
PlusOutlined,
FileOutlined,
ClockCircleOutlined,
} from "@ant-design/icons-vue"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import dayjs from "dayjs"
import {
reviewsApi,
judgesApi,
worksApi,
type ReviewProgress,
type ContestJudge,
type ContestWork,
type UnassignedWork,
} from "@/api/contests"
import { contestsApi, type Contest } from "@/api/contests"
const route = useRoute()
const contestId = Number(route.params.id)
const router = useRouter()
const tenantCode = route.params.tenantCode as string
// Tab
const activeTab = ref<"individual" | "team">("individual")
//
const loading = ref(false)
const progressData = ref<ReviewProgress | null>(null)
const judges = ref<ContestJudge[]>([])
const availableWorks = ref<ContestWork[]>([])
//
const autoAssignLoading = ref(false)
//
const showBatchAssignModal = ref(false)
const batchAssignLoading = ref(false)
const batchAssignForm = reactive({
workIds: [] as number[],
judgeIds: [] as number[],
const dataSource = ref<Contest[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
//
const showQuickAssignModal = ref(false)
const quickAssignLoading = ref(false)
const quickAssignWork = ref<UnassignedWork | null>(null)
const quickAssignJudgeIds = ref<number[]>([])
//
const searchParams = reactive({
contestName: "",
reviewStatus: undefined as string | undefined,
})
//
const judgeColumns = [
{
title: "评委",
key: "judgeName",
width: 200,
},
{
title: "专长",
dataIndex: "specialty",
key: "specialty",
width: 120,
},
{
title: "权重",
dataIndex: "weight",
key: "weight",
width: 80,
customRender: ({ record }: { record: any }) => record.weight || 1,
},
{
title: "已分配",
dataIndex: "assignedCount",
key: "assignedCount",
width: 100,
},
{
title: "已评分",
dataIndex: "scoredCount",
key: "scoredCount",
width: 100,
},
{
title: "待评审",
key: "pendingCount",
width: 100,
},
{
title: "完成进度",
key: "progress",
width: 200,
},
//
const columns = [
{ title: "序号", key: "index", width: 70 },
{ title: "赛事名称", dataIndex: "contestName", key: "contestName", width: 200 },
{ title: "评审状态", key: "reviewStatus", width: 100 },
{ title: "已评审/作品数", key: "reviewedCount", width: 130 },
{ title: "评审时间", key: "reviewTime", width: 180 },
{ title: "操作", key: "action", width: 100, fixed: "right" as const },
]
//
const formatDateTime = (dateStr?: string) => {
//
const getReviewStatusColor = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start || now < start) return "default"
if (end && now > end) return "success"
return "processing"
}
//
const getReviewStatusText = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start || now < start) return "未开始"
if (end && now > end) return "已完成"
return "进行中"
}
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const getReviewStatusColor = () => {
if (!progressData.value?.contest) return "default"
const now = new Date()
const start = new Date(progressData.value.contest.reviewStartTime)
const end = new Date(progressData.value.contest.reviewEndTime)
if (now < start) return "default"
if (now > end) return "red"
return "green"
}
//
const getReviewStatusText = () => {
if (!progressData.value?.contest) return "未知"
const now = new Date()
const start = new Date(progressData.value.contest.reviewStartTime)
const end = new Date(progressData.value.contest.reviewEndTime)
if (now < start) return "未开始"
if (now > end) return "已结束"
return "进行中"
}
//
const fetchProgress = async () => {
//
const fetchList = async () => {
loading.value = true
try {
progressData.value = await reviewsApi.getReviewProgress(contestId)
const contestType = activeTab.value === "individual" ? "individual" : "team"
const response = await contestsApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
contestName: searchParams.contestName || undefined,
contestType,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取评审进度失败")
message.error(error?.response?.data?.message || "获取列表失败")
} finally {
loading.value = false
}
}
//
const fetchJudges = async () => {
try {
judges.value = await judgesApi.getList(contestId)
} catch (error) {
console.error("获取评委列表失败", error)
}
// Tab
const handleTabChange = () => {
pagination.current = 1
fetchList()
}
//
const fetchWorks = async () => {
try {
const response = await worksApi.getList({
page: 1,
pageSize: 100,
contestId,
})
availableWorks.value = response.list
} catch (error) {
console.error("获取作品列表失败", error)
}
//
const handleSearch = () => {
pagination.current = 1
fetchList()
}
//
const handleAutoAssign = async () => {
autoAssignLoading.value = true
try {
const result = await reviewsApi.autoAssignWorks(contestId)
message.success(result.message)
fetchProgress()
} catch (error: any) {
message.error(error?.response?.data?.message || "自动分配失败")
} finally {
autoAssignLoading.value = false
}
//
const handleReset = () => {
searchParams.contestName = ""
searchParams.reviewStatus = undefined
pagination.current = 1
fetchList()
}
//
const handleBatchAssign = async () => {
if (
batchAssignForm.workIds.length === 0 ||
batchAssignForm.judgeIds.length === 0
) {
message.warning("请选择作品和评委")
return
}
batchAssignLoading.value = true
try {
const result = await reviewsApi.batchAssignWorks(contestId, batchAssignForm)
message.success(`成功分配 ${result.created} 条,跳过 ${result.skipped}`)
showBatchAssignModal.value = false
batchAssignForm.workIds = []
batchAssignForm.judgeIds = []
fetchProgress()
} catch (error: any) {
message.error(error?.response?.data?.message || "批量分配失败")
} finally {
batchAssignLoading.value = false
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
//
const handleQuickAssign = (work: UnassignedWork) => {
quickAssignWork.value = work
quickAssignJudgeIds.value = []
showQuickAssignModal.value = true
}
//
const handleQuickAssignSubmit = async () => {
if (!quickAssignWork.value || quickAssignJudgeIds.value.length === 0) {
message.warning("请选择评委")
return
}
quickAssignLoading.value = true
try {
const result = await reviewsApi.batchAssignWorks(contestId, {
workIds: [quickAssignWork.value.id],
judgeIds: quickAssignJudgeIds.value,
})
message.success(`成功分配给 ${result.created} 位评委`)
showQuickAssignModal.value = false
fetchProgress()
} catch (error: any) {
message.error(error?.response?.data?.message || "分配失败")
} finally {
quickAssignLoading.value = false
}
//
const handleViewDetail = (record: Contest) => {
router.push(`/${tenantCode}/contests/reviews/${record.id}/progress?type=${activeTab.value}`)
}
onMounted(() => {
fetchProgress()
fetchJudges()
fetchWorks()
fetchList()
})
</script>
<style scoped>
.review-progress-page {
padding: 24px;
padding: 0;
}
.stats-row {
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
.contest-tabs {
margin-bottom: 16px;
background: #fff;
padding: 0 16px;
border-radius: 8px;
}
.progress-card {
.search-form {
margin-bottom: 16px;
}
.progress-item {
padding: 8px 0;
}
.progress-label {
font-weight: 500;
margin-bottom: 8px;
}
.progress-desc {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.judge-progress-card {
margin-bottom: 16px;
}
.list-card {
margin-bottom: 16px;
min-height: 300px;
}
.time-card {
margin-top: 16px;
}
.assign-time {
font-size: 12px;
color: #999;
padding: 16px;
background: #fff;
border-radius: 8px;
}
</style>

View File

@ -0,0 +1,602 @@
<template>
<div class="progress-detail-page">
<a-card class="mb-4">
<template #title>
<a-space>
<a-button type="text" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<span>{{ contestName }}作品评审进度</span>
</a-space>
</template>
<template #extra>
<a-space>
<a-button type="primary" @click="handleStartReview">
开始评审
</a-button>
<a-button @click="handleEndReview">
结束评审
</a-button>
<a-button @click="handleNotSubmitted">
{{ contestType === 'team' ? '未提交作品队伍' : '未提交作品选手' }}
</a-button>
<a-button>
<template #icon><DownloadOutlined /></template>
导出
</a-button>
</a-space>
</template>
</a-card>
<!-- 搜索表单 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="作品编号">
<a-input
v-model:value="searchParams.workNo"
placeholder="请输入作品编号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.username"
placeholder="请输入报名账号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="评审进度">
<a-select
v-model:value="searchParams.reviewProgress"
placeholder="请选择评审进度"
allow-clear
style="width: 150px"
>
<a-select-option value="not_reviewed">未评审</a-select-option>
<a-select-option value="in_progress">评审中</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
</a-select>
</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 === 'username'">
{{ record.submitterAccountNo || record.registration?.user?.username || "-" }}
</template>
<template v-else-if="column.key === 'judgeScore'">
<span v-if="record.averageScore !== undefined && record.averageScore !== null">
{{ record.averageScore.toFixed(2) }}
</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'reviewProgress'">
<a-tag :color="getProgressColor(record)">
{{ getProgressText(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewScores(record)">
查看
</a-button>
</template>
</template>
</a-table>
<!-- 评审详情抽屉 -->
<a-drawer
v-model:open="scoreDrawerVisible"
title="评审详情"
placement="right"
width="700"
>
<a-table
:columns="scoreColumns"
:data-source="scoreList"
:loading="scoreLoading"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.judge?.nickname || record.judge?.username || "-" }}
</template>
<template v-else-if="column.key === 'phone'">
{{ record.judge?.phone || "-" }}
</template>
<template v-else-if="column.key === 'tenant'">
{{ record.judge?.tenant?.name || "-" }}
</template>
<template v-else-if="column.key === 'score'">
<span v-if="record.score !== undefined && record.score !== null">
{{ record.score }}
</span>
<span v-else class="text-gray">未评分</span>
</template>
<template v-else-if="column.key === 'scoreTime'">
{{ formatDate(record.scoreTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleReplaceJudge(record)">
替换评委
</a-button>
</template>
</template>
</a-table>
</a-drawer>
<!-- 评委替换抽屉 -->
<a-drawer
v-model:open="replaceJudgeDrawerVisible"
title="评委替换"
placement="right"
width="700"
:footer-style="{ textAlign: 'right' }"
>
<!-- 搜索 -->
<a-form layout="inline" class="mb-3">
<a-form-item label="姓名">
<a-input
v-model:value="judgeSearchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="机构信息">
<a-input
v-model:value="judgeSearchParams.tenantName"
placeholder="请输入机构信息"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearchJudges">搜索</a-button>
<a-button class="ml-2" @click="handleResetJudgeSearch">重置</a-button>
</a-form-item>
</a-form>
<!-- 评委列表 -->
<a-table
:columns="judgeSelectColumns"
:data-source="judgeList"
:loading="judgeListLoading"
:pagination="judgePagination"
:row-selection="judgeRowSelection"
row-key="id"
size="small"
@change="handleJudgeTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.nickname || record.username || "-" }}
</template>
<template v-else-if="column.key === 'assignedCount'">
{{ record.contestJudges?.length || 0 }}
</template>
<template v-else-if="column.key === 'tenant'">
{{ record.tenant?.name || "-" }}
</template>
</template>
</a-table>
<template #footer>
<a-space>
<a-button @click="replaceJudgeDrawerVisible = false">取消</a-button>
<a-button type="primary" :loading="replaceLoading" @click="handleConfirmReplace">
确定
</a-button>
</a-space>
</template>
</a-drawer>
<!-- 未提交作品弹框 -->
<a-modal
v-model:open="notSubmittedModalVisible"
:title="contestType === 'team' ? '未提交作品队伍' : '未提交作品选手'"
width="700px"
:footer="null"
>
<a-table
:columns="notSubmittedColumns"
:data-source="notSubmittedList"
:loading="notSubmittedLoading"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ index + 1 }}
</template>
<template v-else-if="column.key === 'name'">
{{ contestType === 'team' ? record.teamName : (record.user?.nickname || '-') }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.user?.username || "-" }}
</template>
</template>
</a-table>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import type { TableProps } from "ant-design-vue"
import {
ArrowLeftOutlined,
SearchOutlined,
ReloadOutlined,
DownloadOutlined,
} from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { contestsApi, worksApi, reviewsApi, type ContestWork } from "@/api/contests"
import { judgesManagementApi, type Judge } from "@/api/judges-management"
const route = useRoute()
const router = useRouter()
const contestId = Number(route.params.id)
const contestType = (route.query.type as string) || "individual"
//
const contestName = ref("")
//
const loading = ref(false)
const dataSource = ref<ContestWork[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
//
const searchParams = reactive({
workNo: "",
username: "",
reviewProgress: undefined as string | undefined,
})
//
const columns = [
{ title: "序号", key: "index", width: 70 },
{ title: "作品编号", dataIndex: "workNo", key: "workNo", width: 120 },
{ title: "报名账号", key: "username", width: 150 },
{ title: "评委评分", key: "judgeScore", width: 100 },
{ title: "评审进度", key: "reviewProgress", width: 100 },
{ title: "操作", key: "action", width: 80, fixed: "right" as const },
]
//
const scoreDrawerVisible = ref(false)
const scoreLoading = ref(false)
const scoreList = ref<any[]>([])
const currentWork = ref<ContestWork | null>(null)
//
const scoreColumns = [
{ title: "评委姓名", key: "judgeName", width: 100 },
{ title: "联系方式", key: "phone", width: 120 },
{ title: "机构信息", key: "tenant", width: 150 },
{ title: "评分", key: "score", width: 80 },
{ title: "评分时间", key: "scoreTime", width: 150 },
{ title: "操作", key: "action", width: 100 },
]
//
const replaceJudgeDrawerVisible = ref(false)
const replaceLoading = ref(false)
const currentReplaceScore = ref<any>(null)
const judgeList = ref<Judge[]>([])
const judgeListLoading = ref(false)
const judgePagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
const judgeSearchParams = reactive({
nickname: "",
tenantName: "",
})
const selectedJudgeKeys = ref<number[]>([])
const selectedJudgeRow = ref<Judge | null>(null)
//
const judgeSelectColumns = [
{ title: "评委姓名", key: "judgeName", width: 120 },
{ title: "已分配赛事数", key: "assignedCount", width: 120 },
{ title: "机构信息", key: "tenant", width: 150 },
]
//
const judgeRowSelection = computed<TableProps["rowSelection"]>(() => ({
type: "radio",
selectedRowKeys: selectedJudgeKeys.value,
onChange: (keys: any, rows: Judge[]) => {
selectedJudgeKeys.value = keys
selectedJudgeRow.value = rows[0] || null
},
}))
//
const notSubmittedModalVisible = ref(false)
const notSubmittedLoading = ref(false)
const notSubmittedList = ref<any[]>([])
//
const notSubmittedColumns = computed(() => {
if (contestType === "team") {
return [
{ title: "序号", key: "index", width: 70 },
{ title: "队伍名称", key: "name", width: 150 },
{ title: "报名账号", key: "username", width: 150 },
]
}
return [
{ title: "序号", key: "index", width: 70 },
{ title: "姓名", key: "name", width: 120 },
{ title: "报名账号", key: "username", width: 150 },
]
})
//
const getProgressColor = (record: ContestWork) => {
if (!record.reviewedCount || record.reviewedCount === 0) return "default"
if (record.reviewedCount >= (record.totalJudgesCount || 1)) return "success"
return "processing"
}
//
const getProgressText = (record: ContestWork) => {
if (!record.reviewedCount || record.reviewedCount === 0) return "未评审"
if (record.reviewedCount >= (record.totalJudgesCount || 1)) return "已完成"
return `${record.reviewedCount}/${record.totalJudgesCount || 0}`
}
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const fetchContestInfo = async () => {
try {
const contest = await contestsApi.getDetail(contestId)
contestName.value = contest.contestName
} catch (error) {
console.error("获取赛事信息失败", error)
}
}
//
const fetchList = async () => {
loading.value = true
try {
const response = await worksApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
contestId,
workNo: searchParams.workNo || undefined,
username: searchParams.username || undefined,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品列表失败")
} finally {
loading.value = false
}
}
//
const fetchJudgeList = async () => {
judgeListLoading.value = true
try {
const response = await judgesManagementApi.getList({
page: judgePagination.current,
pageSize: judgePagination.pageSize,
nickname: judgeSearchParams.nickname || undefined,
})
judgeList.value = response.list
judgePagination.total = response.total
} catch (error) {
message.error("获取评委列表失败")
} finally {
judgeListLoading.value = false
}
}
//
const handleSearch = () => {
pagination.current = 1
fetchList()
}
//
const handleReset = () => {
searchParams.workNo = ""
searchParams.username = ""
searchParams.reviewProgress = undefined
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
//
const handleBack = () => {
router.back()
}
//
const handleStartReview = async () => {
try {
await contestsApi.update(contestId, {
reviewStartTime: new Date().toISOString(),
})
message.success("评审已开始")
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
}
//
const handleEndReview = async () => {
try {
await contestsApi.update(contestId, {
reviewEndTime: new Date().toISOString(),
})
message.success("评审已结束")
} catch (error: any) {
message.error(error?.response?.data?.message || "操作失败")
}
}
//
const handleNotSubmitted = async () => {
notSubmittedModalVisible.value = true
notSubmittedLoading.value = true
try {
// TODO: API
notSubmittedList.value = []
} catch (error) {
message.error("获取未提交作品列表失败")
} finally {
notSubmittedLoading.value = false
}
}
//
const handleViewScores = async (record: ContestWork) => {
currentWork.value = record
scoreDrawerVisible.value = true
scoreLoading.value = true
try {
// TODO: API
const scores = await reviewsApi.getWorkScores(record.id)
scoreList.value = scores
} catch (error) {
message.error("获取评分详情失败")
scoreList.value = []
} finally {
scoreLoading.value = false
}
}
//
const handleReplaceJudge = (record: any) => {
currentReplaceScore.value = record
selectedJudgeKeys.value = []
selectedJudgeRow.value = null
replaceJudgeDrawerVisible.value = true
fetchJudgeList()
}
//
const handleSearchJudges = () => {
judgePagination.current = 1
fetchJudgeList()
}
//
const handleResetJudgeSearch = () => {
judgeSearchParams.nickname = ""
judgeSearchParams.tenantName = ""
judgePagination.current = 1
fetchJudgeList()
}
//
const handleJudgeTableChange = (pag: any) => {
judgePagination.current = pag.current
judgePagination.pageSize = pag.pageSize
fetchJudgeList()
}
//
const handleConfirmReplace = async () => {
if (!selectedJudgeRow.value) {
message.warning("请选择评委")
return
}
replaceLoading.value = true
try {
// TODO: API
await reviewsApi.replaceJudge(currentReplaceScore.value.id, selectedJudgeRow.value.id)
message.success("替换成功")
replaceJudgeDrawerVisible.value = false
//
if (currentWork.value) {
handleViewScores(currentWork.value)
}
} catch (error: any) {
message.error(error?.response?.data?.message || "替换失败")
} finally {
replaceLoading.value = false
}
}
onMounted(() => {
fetchContestInfo()
fetchList()
})
</script>
<style scoped>
.progress-detail-page {
padding: 0;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
}
.mb-3 {
margin-bottom: 12px;
}
.ml-2 {
margin-left: 8px;
}
.text-gray {
color: #999;
}
</style>

View File

@ -0,0 +1,278 @@
<template>
<div class="review-tasks-page">
<a-card class="mb-4">
<template #title>我的评审任务</template>
<template #extra>
<a-button type="primary" @click="fetchList">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</template>
</a-card>
<!-- Tab栏 -->
<a-tabs v-model:activeKey="activeTab" class="contest-tabs" @change="handleTabChange">
<a-tab-pane key="pending" tab="待评审" />
<a-tab-pane key="completed" tab="已评审" />
</a-tabs>
<!-- 搜索表单 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="赛事名称">
<a-input
v-model:value="searchParams.contestName"
placeholder="请输入赛事名称"
allow-clear
style="width: 200px"
/>
</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 === 'contestType'">
<a-tag :color="record.contestType === 'individual' ? 'blue' : 'green'">
{{ record.contestType === 'individual' ? '个人赛' : '团队赛' }}
</a-tag>
</template>
<template v-else-if="column.key === 'reviewProgress'">
<a-progress
:percent="getReviewProgress(record)"
:status="getProgressStatus(record)"
size="small"
/>
<span class="progress-text">{{ record.reviewedCount || 0 }} / {{ record.totalWorksCount || 0 }}</span>
</template>
<template v-else-if="column.key === 'reviewTime'">
<div v-if="record.reviewStartTime || record.reviewEndTime">
<div>{{ formatDate(record.reviewStartTime) }}</div>
<div> {{ formatDate(record.reviewEndTime) }}</div>
</div>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record)">
{{ getStatusText(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
:disabled="!canReview(record)"
@click="handleStartReview(record)"
>
{{ activeTab === 'pending' ? '开始评审' : '查看详情' }}
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && dataSource.length === 0" description="暂无评审任务" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { contestsApi, type Contest } from "@/api/contests"
const route = useRoute()
const router = useRouter()
const tenantCode = route.params.tenantCode as string
// Tab
const activeTab = ref<"pending" | "completed">("pending")
//
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
//
const searchParams = reactive({
contestName: "",
})
//
const columns = computed(() => [
{ title: "序号", key: "index", width: 70 },
{ title: "赛事名称", dataIndex: "contestName", key: "contestName", width: 200 },
{ title: "赛事类型", key: "contestType", width: 100 },
{ title: "评审进度", key: "reviewProgress", width: 180 },
{ title: "评审时间", key: "reviewTime", width: 180 },
{ title: "状态", key: "status", width: 100 },
{ title: "操作", key: "action", width: 120, fixed: "right" as const },
])
//
const getReviewProgress = (record: Contest) => {
if (!record.totalWorksCount || record.totalWorksCount === 0) return 0
return Math.round(((record.reviewedCount || 0) / record.totalWorksCount) * 100)
}
//
const getProgressStatus = (record: Contest) => {
const progress = getReviewProgress(record)
if (progress === 100) return "success"
if (progress > 0) return "active"
return "normal"
}
//
const getStatusColor = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start || now < start) return "default"
if (end && now > end) return "success"
return "processing"
}
//
const getStatusText = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start || now < start) return "未开始"
if (end && now > end) return "已结束"
return "进行中"
}
//
const canReview = (record: Contest) => {
const now = new Date()
const start = record.reviewStartTime ? new Date(record.reviewStartTime) : null
const end = record.reviewEndTime ? new Date(record.reviewEndTime) : null
if (!start) return false
if (now < start) return false
if (end && now > end) return false
return true
}
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const fetchList = async () => {
loading.value = true
try {
// TODO: API
// 使APIAPI
const response = await contestsApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
contestName: searchParams.contestName || undefined,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取评审任务失败")
} finally {
loading.value = false
}
}
// Tab
const handleTabChange = () => {
pagination.current = 1
fetchList()
}
//
const handleSearch = () => {
pagination.current = 1
fetchList()
}
//
const handleReset = () => {
searchParams.contestName = ""
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
// /
const handleStartReview = (record: Contest) => {
//
router.push(`/${tenantCode}/contests/works/${record.id}/list`)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.review-tasks-page {
padding: 0;
}
.contest-tabs {
margin-bottom: 16px;
background: #fff;
padding: 0 16px;
border-radius: 8px;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
}
.progress-text {
margin-left: 8px;
font-size: 12px;
color: #666;
}
.mb-4 {
margin-bottom: 16px;
}
</style>

View File

@ -1,811 +1,202 @@
<template>
<div class="works-page">
<a-card>
<template #title>作品管理</template>
<template #extra>
<a-space>
<a-button
v-permission="'contest:read'"
@click="handleExport"
:loading="exportLoading"
>
<template #icon><DownloadOutlined /></template>
导出作品
</a-button>
</a-space>
</template>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="比赛">
<a-select
v-model:value="searchParams.contestId"
placeholder="请选择比赛"
allow-clear
style="width: 200px"
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="contest in contests"
:key="contest.id"
:value="contest.id"
>
{{ contest.contestName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="作品状态">
<a-select
v-model:value="searchParams.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="submitted">已提交</a-select-option>
<a-select-option value="locked">已锁定</a-select-option>
<a-select-option value="reviewing">评审中</a-select-option>
<a-select-option value="accepted">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="作品标题">
<a-input
v-model:value="searchParams.title"
placeholder="请输入作品标题"
allow-clear
style="width: 180px"
/>
</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 }">
<template v-if="column.key === 'contestName'">
<a @click="handleViewContest(record.contestId)">{{
record.contest?.contestName || "-"
}}</a>
</template>
<template v-else-if="column.key === 'title'">
<a @click="handleViewDetail(record)">{{ record.title }}</a>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'submitter'">
{{
record.registration?.user?.nickname ||
record.submitterAccountNo ||
"-"
}}
</template>
<template v-else-if="column.key === 'version'">
<a-tag v-if="record.isLatest" color="success"
>v{{ record.version }} (最新)</a-tag
>
<a-tag v-else>v{{ record.version }}</a-tag>
</template>
<template v-else-if="column.key === 'scoreInfo'">
<template v-if="record._count?.scores > 0">
<a-tag color="blue">{{ record._count.scores }}人已评</a-tag>
</template>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleViewDetail(record)"
>
查看
</a-button>
<a-button
v-if="record.registrationId"
type="link"
size="small"
@click="handleViewVersions(record.registrationId)"
>
<HistoryOutlined /> 版本
</a-button>
<a-button
v-if="record.status === 'submitted'"
v-permission="'contest:update'"
type="link"
size="small"
@click="handleLockWork(record.id)"
>
锁定
</a-button>
</a-space>
</template>
</template>
</a-table>
<a-card class="mb-4">
<template #title>参赛作品</template>
</a-card>
<!-- 作品详情抽屉 -->
<a-drawer
v-model:open="detailDrawerVisible"
title="作品详情"
width="700"
placement="right"
<!-- Tab栏 -->
<a-tabs v-model:activeKey="activeTab" class="contest-tabs" @change="handleTabChange">
<a-tab-pane key="individual" tab="个人赛" />
<a-tab-pane key="team" tab="团队赛" />
</a-tabs>
<!-- 搜索表单 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="赛事名称">
<a-input
v-model:value="searchParams.contestName"
placeholder="请输入赛事名称"
allow-clear
style="width: 200px"
/>
</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 v-if="currentWork">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="作品编号" :span="2">
{{ currentWork.workNo || "-" }}
</a-descriptions-item>
<a-descriptions-item label="作品标题" :span="2">
{{ currentWork.title }}
</a-descriptions-item>
<a-descriptions-item label="所属比赛" :span="2">
{{ currentWork.contest?.contestName || "-" }}
</a-descriptions-item>
<a-descriptions-item label="提交人">
{{
currentWork.registration?.user?.nickname ||
currentWork.submitterAccountNo ||
"-"
}}
</a-descriptions-item>
<a-descriptions-item label="提交来源">
{{ currentWork.submitSource || "-" }}
</a-descriptions-item>
<a-descriptions-item label="作品状态">
<a-tag :color="getStatusColor(currentWork.status)">
{{ getStatusText(currentWork.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="版本">
<a-tag v-if="currentWork.isLatest" color="success"
>v{{ currentWork.version }} (最新)</a-tag
>
<a-tag v-else>v{{ currentWork.version }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="提交时间" :span="2">
{{ formatDateTime(currentWork.submitTime) }}
</a-descriptions-item>
<a-descriptions-item
v-if="currentWork.description"
label="作品描述"
:span="2"
>
<div v-html="currentWork.description"></div>
</a-descriptions-item>
<a-descriptions-item
v-if="currentWork.previewUrl"
label="预览链接"
:span="2"
>
<a :href="currentWork.previewUrl" target="_blank">{{
currentWork.previewUrl
}}</a>
</a-descriptions-item>
</a-descriptions>
<!-- 附件列表 -->
<a-divider orientation="left">附件列表</a-divider>
<a-table
:columns="attachmentColumns"
:data-source="currentWork.attachments || []"
:pagination="false"
size="small"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileName'">
<a :href="record.fileUrl" target="_blank">
<FileOutlined /> {{ record.fileName }}
</a>
</template>
<template v-else-if="column.key === 'size'">
{{ formatFileSize(record.size) }}
</template>
</template>
</a-table>
<!-- 评分信息 -->
<a-divider orientation="left">评分信息</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-statistic
title="评分人数"
:value="currentWork._count?.scores || 0"
suffix="人"
/>
</a-col>
<a-col :span="8">
<a-statistic
title="分配评委"
:value="currentWork._count?.assignments || 0"
suffix="人"
/>
</a-col>
<a-col :span="8">
<a-button type="primary" @click="handleViewScores"
>查看评分详情</a-button
>
</a-col>
</a-row>
<!-- AI元数据 -->
<template v-if="currentWork.aiModelMeta">
<a-divider orientation="left">AI模型信息</a-divider>
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="模型名称">
{{ currentWork.aiModelMeta.modelName || "-" }}
</a-descriptions-item>
<a-descriptions-item label="模型版本">
{{ currentWork.aiModelMeta.modelVersion || "-" }}
</a-descriptions-item>
<a-descriptions-item
v-if="currentWork.aiModelMeta.parameters"
label="参数"
:span="2"
>
<pre style="margin: 0; white-space: pre-wrap">{{
JSON.stringify(currentWork.aiModelMeta.parameters, null, 2)
}}</pre>
</a-descriptions-item>
</a-descriptions>
<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 === 'registrationCount'">
{{ activeTab === 'team' ? (record._count?.teams || 0) : (record._count?.registrations || 0) }}
</template>
<template v-else-if="column.key === 'worksCount'">
{{ record._count?.works || 0 }} / {{ activeTab === 'team' ? (record._count?.teams || 0) : (record._count?.registrations || 0) }}
</template>
<template v-else-if="column.key === 'contestTime'">
<div v-if="record.startTime || record.endTime">
<div>{{ formatDate(record.startTime) }}</div>
<div> {{ formatDate(record.endTime) }}</div>
</div>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">
查看详情
</a-button>
</template>
</template>
</a-drawer>
<!-- 版本历史弹窗 -->
<a-modal
v-model:open="versionModalVisible"
title="版本历史"
:footer="null"
width="800px"
>
<a-spin :spinning="versionLoading">
<a-timeline>
<a-timeline-item
v-for="version in versionHistory"
:key="version.id"
:color="version.isLatest ? 'green' : 'gray'"
>
<template #dot v-if="version.isLatest">
<CheckCircleOutlined style="font-size: 16px; color: #52c41a" />
</template>
<a-card
size="small"
:bordered="false"
:bodyStyle="{ padding: '12px' }"
>
<a-row :gutter="16" align="middle">
<a-col :span="4">
<a-tag :color="version.isLatest ? 'success' : 'default'">
v{{ version.version
}}{{ version.isLatest ? " (最新)" : "" }}
</a-tag>
</a-col>
<a-col :span="6">
<span style="color: #666">{{ version.title }}</span>
</a-col>
<a-col :span="4">
<a-tag :color="getStatusColor(version.status)">
{{ getStatusText(version.status) }}
</a-tag>
</a-col>
<a-col :span="6">
{{ formatDateTime(version.submitTime) }}
</a-col>
<a-col :span="4">
<a-button
type="link"
size="small"
@click="handleViewVersionDetail(version)"
>
查看
</a-button>
</a-col>
</a-row>
</a-card>
</a-timeline-item>
</a-timeline>
<a-empty
v-if="versionHistory.length === 0 && !versionLoading"
description="暂无版本记录"
/>
</a-spin>
</a-modal>
<!-- 评分详情弹窗 -->
<a-modal
v-model:open="scoresModalVisible"
title="评分详情"
:footer="null"
width="800px"
>
<a-spin :spinning="scoresLoading">
<a-table
:columns="scoreColumns"
:data-source="workScores"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judge'">
{{ record.judge?.nickname || record.judgeName || "-" }}
</template>
<template v-else-if="column.key === 'totalScore'">
<a-tag color="blue" style="font-size: 14px">{{
record.totalScore
}}</a-tag>
</template>
<template v-else-if="column.key === 'dimensionScores'">
<template v-if="record.dimensionScores">
<a-space wrap>
<span
v-for="(score, name) in parseDimensionScores(
record.dimensionScores
)"
:key="name"
>
{{ name }}: {{ score }}
</span>
</a-space>
</template>
<span v-else>-</span>
</template>
</template>
</a-table>
<a-empty
v-if="workScores.length === 0 && !scoresLoading"
description="暂无评分记录"
/>
</a-spin>
</a-modal>
</a-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import {
SearchOutlined,
ReloadOutlined,
DownloadOutlined,
HistoryOutlined,
FileOutlined,
CheckCircleOutlined,
} from "@ant-design/icons-vue"
import { useListRequest } from "@/composables/useListRequest"
import {
worksApi,
contestsApi,
reviewsApi,
type ContestWork,
type QueryWorkParams,
type ContestWorkScore,
} from "@/api/contests"
import type { Contest } from "@/api/contests"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { contestsApi, type Contest } from "@/api/contests"
const router = useRouter()
const route = useRoute()
const router = useRouter()
const tenantCode = route.params.tenantCode as string
// URLcontestId
const urlContestId = route.params.id ? Number(route.params.id) : undefined
// Tab
const activeTab = ref<"individual" | "team">("individual")
// 使
const {
loading,
dataSource,
pagination,
searchParams,
fetchList,
resetSearch,
search,
handleTableChange,
} = useListRequest<ContestWork, QueryWorkParams>({
requestFn: worksApi.getList,
defaultSearchParams: { contestId: urlContestId } as QueryWorkParams,
defaultPageSize: 10,
errorMessage: "获取作品列表失败",
//
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
const contests = ref<Contest[]>([])
const exportLoading = ref(false)
//
const detailDrawerVisible = ref(false)
const currentWork = ref<ContestWork | null>(null)
//
const versionModalVisible = ref(false)
const versionLoading = ref(false)
const versionHistory = ref<ContestWork[]>([])
//
const scoresModalVisible = ref(false)
const scoresLoading = ref(false)
const workScores = ref<ContestWorkScore[]>([])
//
const searchParams = reactive({
contestName: "",
})
//
const columns = [
const columns = computed(() => [
{ title: "序号", key: "index", width: 70 },
{ title: "赛事名称", dataIndex: "contestName", key: "contestName", width: 200 },
{
title: "比赛名称",
key: "contestName",
dataIndex: ["contest", "contestName"],
width: 180,
title: activeTab.value === "team" ? "报名队伍数" : "报名人数",
key: "registrationCount",
width: 120
},
{
title: "作品编号",
key: "workNo",
dataIndex: "workNo",
width: 120,
title: "已递交/应递交作品数",
key: "worksCount",
width: 160
},
{
title: "作品标题",
key: "title",
dataIndex: "title",
width: 180,
},
{
title: "提交人",
key: "submitter",
width: 120,
},
{
title: "版本",
key: "version",
width: 100,
},
{
title: "状态",
key: "status",
dataIndex: "status",
width: 90,
},
{
title: "评分",
key: "scoreInfo",
width: 100,
},
{
title: "提交时间",
key: "submitTime",
dataIndex: "submitTime",
width: 150,
customRender: ({ record }: { record: ContestWork }) => {
return dayjs(record.submitTime).format("YYYY-MM-DD HH:mm")
},
},
{
title: "操作",
key: "action",
width: 160,
fixed: "right" as const,
},
]
{ title: "赛事时间", key: "contestTime", width: 180 },
{ title: "操作", key: "action", width: 100, fixed: "right" as const },
])
//
const attachmentColumns = [
{
title: "文件名",
key: "fileName",
dataIndex: "fileName",
width: 300,
},
{
title: "类型",
key: "fileType",
dataIndex: "fileType",
width: 100,
},
{
title: "大小",
key: "size",
dataIndex: "size",
width: 100,
},
{
title: "上传时间",
key: "createTime",
dataIndex: "createTime",
width: 160,
customRender: ({ record }: any) => {
return record.createTime
? dayjs(record.createTime).format("YYYY-MM-DD HH:mm")
: "-"
},
},
]
//
const scoreColumns = [
{
title: "评委",
key: "judge",
width: 120,
},
{
title: "总分",
key: "totalScore",
dataIndex: "totalScore",
width: 100,
},
{
title: "维度评分",
key: "dimensionScores",
width: 250,
},
{
title: "评语",
key: "comments",
dataIndex: "comments",
width: 200,
},
{
title: "评分时间",
key: "scoreTime",
dataIndex: "scoreTime",
width: 160,
customRender: ({ record }: { record: ContestWorkScore }) => {
return record.scoreTime
? dayjs(record.scoreTime).format("YYYY-MM-DD HH:mm")
: "-"
},
},
]
//
const formatDateTime = (dateStr?: string) => {
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const formatFileSize = (size?: string | number) => {
if (!size) return "-"
const bytes = typeof size === "string" ? parseInt(size) : size
if (isNaN(bytes)) return size
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
}
//
const parseDimensionScores = (dimensionScores: any) => {
if (!dimensionScores) return {}
//
const fetchList = async () => {
loading.value = true
try {
return typeof dimensionScores === "string"
? JSON.parse(dimensionScores)
: dimensionScores
} catch {
return {}
const contestType = activeTab.value === "individual" ? "individual" : "team"
const response = await contestsApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
contestName: searchParams.contestName || undefined,
contestType,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取列表失败")
} finally {
loading.value = false
}
}
//
const getStatusColor = (status?: string) => {
switch (status) {
case "accepted":
return "success"
case "rejected":
return "error"
case "reviewing":
return "processing"
case "locked":
return "warning"
default:
return "default"
}
}
//
const getStatusText = (status?: string) => {
switch (status) {
case "accepted":
return "已通过"
case "rejected":
return "已拒绝"
case "reviewing":
return "评审中"
case "locked":
return "已锁定"
default:
return "已提交"
}
}
//
const fetchContests = async () => {
try {
const response = await contestsApi.getList({ page: 1, pageSize: 100 })
contests.value = response.list
} catch (error) {
console.error("获取比赛列表失败", error)
}
}
//
const filterOption = (input: string, option: any) => {
return option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
// Tab
const handleTabChange = () => {
pagination.current = 1
fetchList()
}
//
const handleSearch = () => {
search()
pagination.current = 1
fetchList()
}
//
//
const handleReset = () => {
resetSearch()
searchParams.contestName = ""
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
//
const handleViewDetail = async (record: ContestWork) => {
try {
const detail = await worksApi.getDetail(record.id)
currentWork.value = detail
detailDrawerVisible.value = true
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品详情失败")
}
}
//
const handleViewVersions = async (registrationId: number) => {
versionModalVisible.value = true
versionLoading.value = true
try {
const versions = await worksApi.getVersions(registrationId)
versionHistory.value = versions.sort((a, b) => b.version - a.version)
} catch (error: any) {
message.error("获取版本历史失败")
versionHistory.value = []
} finally {
versionLoading.value = false
}
}
//
const handleViewVersionDetail = async (version: ContestWork) => {
versionModalVisible.value = false
try {
const detail = await worksApi.getDetail(version.id)
currentWork.value = detail
detailDrawerVisible.value = true
} catch (error: any) {
message.error("获取版本详情失败")
}
}
//
const handleViewScores = async () => {
if (!currentWork.value) return
scoresModalVisible.value = true
scoresLoading.value = true
try {
const scores = await reviewsApi.getWorkScores(currentWork.value.id)
workScores.value = scores
} catch (error: any) {
message.error("获取评分详情失败")
workScores.value = []
} finally {
scoresLoading.value = false
}
}
//
const handleViewContest = (contestId: number) => {
router.push(`/${tenantCode}/contests/${contestId}`)
}
//
const handleLockWork = async (workId: number) => {
// TODO:
message.info("锁定作品功能待实现")
}
//
const handleExport = async () => {
if (!searchParams.contestId) {
message.warning("请先选择一个比赛")
return
}
exportLoading.value = true
try {
const response = await worksApi.getList({
contestId: searchParams.contestId,
page: 1,
pageSize: 100,
})
if (response.list.length === 0) {
message.warning("暂无作品数据")
return
}
const headers = [
"作品ID",
"作品编号",
"作品标题",
"提交人",
"版本",
"状态",
"提交时间",
"评分人数",
]
const rows = response.list.map((item) => [
item.id,
item.workNo || "",
item.title,
item.registration?.user?.nickname || item.submitterAccountNo || "",
`v${item.version}${item.isLatest ? "(最新)" : ""}`,
getStatusText(item.status),
formatDateTime(item.submitTime),
item._count?.scores || 0,
])
const csvContent = [
headers.join(","),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")),
].join("\n")
const blob = new Blob(["\ufeff" + csvContent], {
type: "text/csv;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `作品列表_${dayjs().format("YYYYMMDD_HHmmss")}.csv`
link.click()
URL.revokeObjectURL(url)
message.success("导出成功")
} catch (error: any) {
message.error(error?.response?.data?.message || "导出失败")
} finally {
exportLoading.value = false
}
const handleViewDetail = (record: Contest) => {
router.push(`/${tenantCode}/contests/works/${record.id}/list?type=${activeTab.value}`)
}
onMounted(() => {
fetchContests()
if (urlContestId) {
searchParams.contestId = urlContestId
}
fetchList()
})
</script>
<style scoped>
.works-page {
padding: 0;
}
.contest-tabs {
margin-bottom: 16px;
background: #fff;
padding: 0 16px;
border-radius: 8px;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,632 @@
<template>
<div class="works-detail-page">
<a-card class="mb-4">
<template #title>
<a-space>
<a-button type="text" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<span>{{ contestName }}参赛作品</span>
</a-space>
</template>
<template #extra>
<a-button
type="primary"
:disabled="selectedRowKeys.length === 0"
@click="handleBatchAssign"
>
分配评委
</a-button>
</template>
</a-card>
<!-- 搜索表单 -->
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item :label="contestType === 'team' ? '队伍名称' : '选手名称'">
<a-input
v-model:value="searchParams.name"
:placeholder="contestType === 'team' ? '请输入队伍名称' : '请输入选手名称'"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.username"
placeholder="请输入报名账号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="作品编号">
<a-input
v-model:value="searchParams.workNo"
placeholder="请输入作品编号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="分配状态">
<a-select
v-model:value="searchParams.assignStatus"
placeholder="请选择"
allow-clear
style="width: 120px"
>
<a-select-option value="assigned">已分配</a-select-option>
<a-select-option value="unassigned">未分配</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="递交时间">
<a-range-picker
v-model:value="searchParams.submitTimeRange"
style="width: 240px"
/>
</a-form-item>
<a-form-item label="机构">
<a-select
v-model:value="searchParams.tenantId"
placeholder="请选择机构"
allow-clear
style="width: 150px"
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="tenant in tenants"
:key="tenant.id"
:value="tenant.id"
>
{{ tenant.name }}
</a-select-option>
</a-select>
</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-selection="rowSelection"
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 @click="handleViewWork(record)">{{ record.workNo || '-' }}</a>
</template>
<template v-else-if="column.key === 'username'">
{{ record.submitterAccountNo || record.registration?.user?.username || '-' }}
</template>
<template v-else-if="column.key === 'name'">
<template v-if="contestType === 'team'">
{{ record.registration?.team?.teamName || '-' }}
</template>
<template v-else>
{{ record.registration?.user?.nickname || '-' }}
</template>
</template>
<template v-else-if="column.key === 'submitTime'">
{{ formatDate(record.submitTime) }}
</template>
<template v-else-if="column.key === 'assignStatus'">
<a-tag v-if="record._count?.assignments > 0" color="success">已分配</a-tag>
<a-tag v-else color="default">未分配</a-tag>
</template>
<template v-else-if="column.key === 'judges'">
<template v-if="record.assignments && record.assignments.length > 0">
<a-space wrap>
<a-tag v-for="assignment in record.assignments" :key="assignment.id">
{{ assignment.judge?.nickname || assignment.judge?.username || '-' }}
</a-tag>
</a-space>
</template>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleAssignJudge(record)">
分配评委
</a-button>
</template>
</template>
</a-table>
<!-- 作品详情弹框 -->
<a-modal
v-model:open="workModalVisible"
title="作品详情"
width="700px"
:footer="null"
>
<template v-if="currentWork">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="作品编号" :span="2">
{{ currentWork.workNo || '-' }}
</a-descriptions-item>
<a-descriptions-item label="作品标题" :span="2">
{{ currentWork.title }}
</a-descriptions-item>
<a-descriptions-item label="所属比赛" :span="2">
{{ currentWork.contest?.contestName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="提交人">
{{ currentWork.registration?.user?.nickname || currentWork.submitterAccountNo || '-' }}
</a-descriptions-item>
<a-descriptions-item label="报名账号">
{{ currentWork.registration?.user?.username || '-' }}
</a-descriptions-item>
<a-descriptions-item label="提交时间" :span="2">
{{ formatDate(currentWork.submitTime) }}
</a-descriptions-item>
<a-descriptions-item v-if="currentWork.description" label="作品描述" :span="2">
<div v-html="currentWork.description"></div>
</a-descriptions-item>
<a-descriptions-item v-if="currentWork.previewUrl" label="预览链接" :span="2">
<a :href="currentWork.previewUrl" target="_blank">{{ currentWork.previewUrl }}</a>
</a-descriptions-item>
</a-descriptions>
<!-- 附件列表 -->
<a-divider orientation="left">附件列表</a-divider>
<a-table
:columns="attachmentColumns"
:data-source="currentWork.attachments || []"
:pagination="false"
size="small"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileName'">
<a :href="record.fileUrl" target="_blank">
<FileOutlined /> {{ record.fileName }}
</a>
</template>
<template v-else-if="column.key === 'size'">
{{ formatFileSize(record.size) }}
</template>
</template>
</a-table>
</template>
</a-modal>
<!-- 分配评委弹框 -->
<a-modal
v-model:open="assignModalVisible"
title="分配评委"
width="800px"
:confirm-loading="assignLoading"
@ok="handleConfirmAssign"
>
<!-- 搜索 -->
<a-form layout="inline" class="mb-3">
<a-form-item label="评委姓名">
<a-input
v-model:value="judgeSearchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="机构">
<a-input
v-model:value="judgeSearchParams.tenantName"
placeholder="请输入机构"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearchJudges">搜索</a-button>
<a-button class="ml-2" @click="handleResetJudgeSearch">重置</a-button>
</a-form-item>
</a-form>
<!-- 评委列表 -->
<a-table
:columns="judgeColumns"
:data-source="judgeList"
:loading="judgeListLoading"
:pagination="judgePagination"
:row-selection="judgeRowSelection"
row-key="id"
size="small"
@change="handleJudgeTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.judge?.nickname || record.judge?.username || '-' }}
</template>
<template v-else-if="column.key === 'phone'">
{{ record.judge?.phone || '-' }}
</template>
<template v-else-if="column.key === 'tenant'">
{{ record.judge?.tenant?.name || '-' }}
</template>
<template v-else-if="column.key === 'assignedCount'">
{{ record._count?.assignedContestWorks || 0 }}
</template>
</template>
</a-table>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import type { TableProps } from "ant-design-vue"
import type { Dayjs } from "dayjs"
import {
ArrowLeftOutlined,
SearchOutlined,
ReloadOutlined,
FileOutlined,
} from "@ant-design/icons-vue"
import dayjs from "dayjs"
import {
contestsApi,
worksApi,
reviewsApi,
judgesApi,
type ContestWork,
type ContestJudge,
} from "@/api/contests"
interface Tenant {
id: number
name: string
}
const route = useRoute()
const router = useRouter()
const contestId = Number(route.params.id)
const contestType = (route.query.type as string) || "individual"
//
const contestName = ref("")
//
const tenants = ref<Tenant[]>([])
//
const loading = ref(false)
const dataSource = ref<ContestWork[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
//
const searchParams = reactive({
name: "",
username: "",
workNo: "",
assignStatus: undefined as string | undefined,
submitTimeRange: null as [Dayjs, Dayjs] | null,
tenantId: undefined as number | undefined,
})
//
const selectedRowKeys = ref<number[]>([])
const selectedRows = ref<ContestWork[]>([])
//
const rowSelection = computed<TableProps["rowSelection"]>(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any, rows: ContestWork[]) => {
selectedRowKeys.value = keys
selectedRows.value = rows
},
}))
//
const columns = computed(() => [
{ title: "序号", key: "index", width: 70 },
{ title: "作品编号", key: "workNo", width: 150 },
{ title: "报名账号", key: "username", width: 120 },
{
title: contestType === "team" ? "队伍名称" : "选手姓名",
key: "name",
width: 150
},
{ title: "递交时间", key: "submitTime", width: 160 },
{ title: "分配状态", key: "assignStatus", width: 100 },
{ title: "评委", key: "judges", width: 200 },
{ title: "操作", key: "action", width: 100, fixed: "right" as const },
])
//
const attachmentColumns = [
{ title: "文件名", key: "fileName", dataIndex: "fileName", width: 300 },
{ title: "类型", key: "fileType", dataIndex: "fileType", width: 100 },
{ title: "大小", key: "size", dataIndex: "size", width: 100 },
]
//
const workModalVisible = ref(false)
const currentWork = ref<ContestWork | null>(null)
//
const assignModalVisible = ref(false)
const assignLoading = ref(false)
const currentAssignWork = ref<ContestWork | null>(null)
const isBatchAssign = ref(false)
//
const judgeList = ref<ContestJudge[]>([])
const judgeListLoading = ref(false)
const judgePagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
const judgeSearchParams = reactive({
nickname: "",
tenantName: "",
})
const selectedJudgeKeys = ref<number[]>([])
const selectedJudgeRows = ref<ContestJudge[]>([])
//
const judgeRowSelection = computed<TableProps["rowSelection"]>(() => ({
selectedRowKeys: selectedJudgeKeys.value,
onChange: (keys: any, rows: ContestJudge[]) => {
selectedJudgeKeys.value = keys
selectedJudgeRows.value = rows
},
}))
//
const judgeColumns = [
{ title: "评委姓名", key: "judgeName", width: 120 },
{ title: "联系方式", key: "phone", width: 130 },
{ title: "机构", key: "tenant", width: 150 },
{ title: "已分配作品数", key: "assignedCount", width: 120 },
]
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const formatFileSize = (size?: string | number) => {
if (!size) return "-"
const bytes = typeof size === "string" ? parseInt(size) : size
if (isNaN(bytes)) return size
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
}
//
const filterOption = (input: string, option: any) => {
return option.children?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
//
const fetchContestInfo = async () => {
try {
const contest = await contestsApi.getDetail(contestId)
contestName.value = contest.contestName
} catch (error) {
console.error("获取赛事信息失败", error)
}
}
//
const fetchTenants = async () => {
try {
// TODO:
tenants.value = []
} catch (error) {
console.error("获取租户列表失败", error)
}
}
//
const fetchList = async () => {
loading.value = true
try {
const response = await worksApi.getList({
page: pagination.current,
pageSize: pagination.pageSize,
contestId,
workNo: searchParams.workNo || undefined,
username: searchParams.username || undefined,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品列表失败")
} finally {
loading.value = false
}
}
//
const fetchJudgeList = async () => {
judgeListLoading.value = true
try {
const response = await judgesApi.getList(contestId)
judgeList.value = response
judgePagination.total = response.length
} catch (error) {
message.error("获取评委列表失败")
} finally {
judgeListLoading.value = false
}
}
//
const handleSearch = () => {
pagination.current = 1
fetchList()
}
//
const handleReset = () => {
searchParams.name = ""
searchParams.username = ""
searchParams.workNo = ""
searchParams.assignStatus = undefined
searchParams.submitTimeRange = null
searchParams.tenantId = undefined
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
//
const handleBack = () => {
router.back()
}
//
const handleViewWork = async (record: ContestWork) => {
try {
const detail = await worksApi.getDetail(record.id)
currentWork.value = detail
workModalVisible.value = true
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品详情失败")
}
}
//
const handleAssignJudge = (record: ContestWork) => {
currentAssignWork.value = record
isBatchAssign.value = false
selectedJudgeKeys.value = []
selectedJudgeRows.value = []
assignModalVisible.value = true
fetchJudgeList()
}
//
const handleBatchAssign = () => {
if (selectedRowKeys.value.length === 0) {
message.warning("请先选择作品")
return
}
isBatchAssign.value = true
selectedJudgeKeys.value = []
selectedJudgeRows.value = []
assignModalVisible.value = true
fetchJudgeList()
}
//
const handleSearchJudges = () => {
judgePagination.current = 1
fetchJudgeList()
}
//
const handleResetJudgeSearch = () => {
judgeSearchParams.nickname = ""
judgeSearchParams.tenantName = ""
judgePagination.current = 1
fetchJudgeList()
}
//
const handleJudgeTableChange = (pag: any) => {
judgePagination.current = pag.current
judgePagination.pageSize = pag.pageSize
fetchJudgeList()
}
//
const handleConfirmAssign = async () => {
if (selectedJudgeRows.value.length === 0) {
message.warning("请选择评委")
return
}
assignLoading.value = true
try {
const judgeIds = selectedJudgeRows.value.map(j => j.judgeId)
if (isBatchAssign.value) {
//
await reviewsApi.batchAssignWorks(contestId, {
workIds: selectedRowKeys.value,
judgeIds,
})
message.success("批量分配成功")
selectedRowKeys.value = []
selectedRows.value = []
} else if (currentAssignWork.value) {
//
await reviewsApi.assignWork(contestId, {
workId: currentAssignWork.value.id,
judgeIds,
})
message.success("分配成功")
}
assignModalVisible.value = false
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || "分配失败")
} finally {
assignLoading.value = false
}
}
onMounted(() => {
fetchContestInfo()
fetchTenants()
fetchList()
})
</script>
<style scoped>
.works-detail-page {
padding: 0;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
}
.mb-3 {
margin-bottom: 12px;
}
.mb-4 {
margin-bottom: 16px;
}
.ml-2 {
margin-left: 8px;
}
</style>

View File

@ -2,16 +2,48 @@
<div class="error-page">
<a-result status="404" title="404" sub-title="抱歉您访问的页面不存在">
<template #extra>
<a-button type="primary" @click="$router.push({ path: '/' })">
返回首页
</a-button>
<a-space>
<a-button type="primary" @click="handleGoHome">
返回首页
</a-button>
<a-button v-if="isAuthenticated" @click="handleLogout">
退出登录
</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
// 404 page
import { computed } from "vue"
import { useRouter, useRoute } from "vue-router"
import { useAuthStore } from "@/stores/auth"
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const isAuthenticated = computed(() => authStore.isAuthenticated)
const handleGoHome = () => {
const tenantCode = authStore.user?.tenantCode || route.params.tenantCode
if (tenantCode) {
router.push(`/${tenantCode}`)
} else {
router.push("/")
}
}
const handleLogout = async () => {
const tenantCode = authStore.user?.tenantCode || route.params.tenantCode
await authStore.logout()
if (tenantCode) {
router.push(`/${tenantCode}/login`)
} else {
router.push("/login")
}
}
</script>
<style scoped lang="scss">