feat: update frontend for four-tier role system

Add Student Admin and Teacher Admin roles to constants, types, store,
permissions, routes, and admin UI. Teacher Admin sees contests and
problemsets in sidebar; Student Admin sees only problems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:13:39 -06:00
parent 8444d6e21a
commit 2fbcbd07c5
11 changed files with 112 additions and 52 deletions

View File

@@ -24,7 +24,7 @@ const isNotRegularUser = computed(
> >
{{ getUserRole(props.user.admin_type).label }} {{ getUserRole(props.user.admin_type).label }}
</n-tag> </n-tag>
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.ADMIN"> <n-tag size="small" v-if="props.user.admin_type === USER_TYPE.STUDENT_ADMIN || props.user.admin_type === USER_TYPE.TEACHER_ADMIN">
{{ {{
props.user.problem_permission === PROBLEM_PERMISSION.ALL props.user.problem_permission === PROBLEM_PERMISSION.ALL
? "全部" ? "全部"

View File

@@ -38,7 +38,8 @@ const userEditing = ref<User | null>(null)
const adminOptions = [ const adminOptions = [
{ label: "全部用户", value: "" }, { label: "全部用户", value: "" },
{ label: "管理员", value: USER_TYPE.ADMIN }, { label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN }, { label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
] ]
@@ -106,7 +107,8 @@ const columns: DataTableColumn<User>[] = [
const options: SelectOption[] = [ const options: SelectOption[] = [
{ label: "普通", value: USER_TYPE.REGULAR_USER }, { label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "管理员", value: USER_TYPE.ADMIN }, { label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN }, { label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
] ]
@@ -166,7 +168,7 @@ function createNewUser() {
username: "", username: "",
real_name: "", real_name: "",
email: "", email: "",
admin_type: "Admin", admin_type: "Student Admin",
problem_permission: "None", problem_permission: "None",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
@@ -312,7 +314,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-input v-model:value="password" /> <n-input v-model:value="password" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi <n-form-item-gi
v-if="!create && userEditing.admin_type === USER_TYPE.ADMIN" v-if="!create && (userEditing.admin_type === USER_TYPE.STUDENT_ADMIN || userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)"
:span="1" :span="1"
label="出题权限" label="出题权限"
> >

View File

@@ -40,7 +40,9 @@ router.beforeEach(async (to, from, next) => {
if ( if (
to.matched.some( to.matched.some(
(record) => (record) =>
record.meta.requiresSuperAdmin || record.meta.requiresProblemPermission, record.meta.requiresSuperAdmin ||
record.meta.requiresTeacherAdmin ||
record.meta.requiresProblemPermission,
) )
) { ) {
if (!storage.get(STORAGE_KEY.AUTHED)) { if (!storage.get(STORAGE_KEY.AUTHED)) {
@@ -63,6 +65,13 @@ router.beforeEach(async (to, from, next) => {
next("/") next("/")
return return
} }
} else if (
to.matched.some((record) => record.meta.requiresTeacherAdmin)
) {
if (!userStore.isTeacherOrAbove) {
next("/")
return
}
} else if ( } else if (
to.matched.some((record) => record.meta.requiresProblemPermission) to.matched.some((record) => record.meta.requiresProblemPermission)
) { ) {

View File

@@ -182,48 +182,48 @@ export const admins: RouteRecordRaw = {
path: "contest/list", path: "contest/list",
name: "admin contest list", name: "admin contest list",
component: () => import("admin/contest/list.vue"), component: () => import("admin/contest/list.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/create", path: "contest/create",
name: "admin contest create", name: "admin contest create",
component: () => import("admin/contest/detail.vue"), component: () => import("admin/contest/detail.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/edit/:contestID", path: "contest/edit/:contestID",
name: "admin contest edit", name: "admin contest edit",
component: () => import("admin/contest/detail.vue"), component: () => import("admin/contest/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/list", path: "contest/:contestID/problem/list",
name: "admin contest problem list", name: "admin contest problem list",
component: () => import("admin/problem/list.vue"), component: () => import("admin/problem/list.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/create", path: "contest/:contestID/problem/create",
name: "admin contest problem create", name: "admin contest problem create",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/edit/:problemID", path: "contest/:contestID/problem/edit/:problemID",
name: "admin contest problem edit", name: "admin contest problem edit",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/helper", path: "contest/:contestID/helper",
name: "admin contest helper", name: "admin contest helper",
component: () => import("admin/contest/helper.vue"), component: () => import("admin/contest/helper.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 只有super_admin可以访问的路由 // 只有super_admin可以访问的路由
{ {
@@ -293,27 +293,27 @@ export const admins: RouteRecordRaw = {
path: "problemset/list", path: "problemset/list",
name: "admin problemset list", name: "admin problemset list",
component: () => import("admin/problemset/list.vue"), component: () => import("admin/problemset/list.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problemset/create", path: "problemset/create",
name: "admin problemset create", name: "admin problemset create",
component: () => import("admin/problemset/edit.vue"), component: () => import("admin/problemset/edit.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problemset/edit/:problemSetId", path: "problemset/edit/:problemSetId",
name: "admin problemset edit", name: "admin problemset edit",
component: () => import("admin/problemset/edit.vue"), component: () => import("admin/problemset/edit.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problemset/:problemSetId", path: "problemset/:problemSetId",
name: "admin problemset detail", name: "admin problemset detail",
component: () => import("admin/problemset/detail.vue"), component: () => import("admin/problemset/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
], ],
} }

View File

@@ -166,7 +166,7 @@ const menus = computed<MenuOption[]>(() => [
label: () => label: () =>
h( h(
RouterLink, RouterLink,
{ to: userStore.isTheAdmin ? "/admin/problem/list" : "/admin" }, { to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list" },
{ default: () => "后台" }, { default: () => "后台" },
), ),
show: userStore.isAdminRole, show: userStore.isAdminRole,

View File

@@ -19,8 +19,8 @@ const options = computed<MenuOption[]>(() => {
}, },
] ]
// admin 可以访问的功能 // Student Admin: only problems
if (userStore.isTheAdmin) { if (userStore.isStudentAdmin) {
baseOptions.push({ baseOptions.push({
label: () => label: () =>
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }), h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
@@ -28,7 +28,40 @@ const options = computed<MenuOption[]>(() => {
}) })
} }
// super_admin 可以访问的功能 // Teacher Admin: problems + contests + problemsets
if (userStore.isTeacherAdmin) {
baseOptions.push(
{
label: () =>
h(
RouterLink,
{ to: "/admin/problem/list" },
{ default: () => "题目" },
),
key: "admin problem list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/contest/list" },
{ default: () => "比赛" },
),
key: "admin contest list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problemset/list" },
{ default: () => "题单" },
),
key: "admin problemset list",
},
)
}
// Super Admin: everything
if (userStore.isSuperAdmin) { if (userStore.isSuperAdmin) {
baseOptions.push( baseOptions.push(
{ {

View File

@@ -13,10 +13,21 @@ export const useUserStore = defineStore("user", () => {
const isAuthed = computed(() => !!user.value?.email) const isAuthed = computed(() => !!user.value?.email)
const isAdminRole = computed( const isAdminRole = computed(
() => () =>
user.value?.admin_type === USER_TYPE.ADMIN || user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
)
const isStudentAdmin = computed(
() => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
)
const isTeacherAdmin = computed(
() => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
)
const isTeacherOrAbove = computed(
() =>
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN, user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
) )
const isTheAdmin = computed(() => user.value?.admin_type === USER_TYPE.ADMIN)
const isSuperAdmin = computed( const isSuperAdmin = computed(
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN, () => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
) )
@@ -47,7 +58,9 @@ export const useUserStore = defineStore("user", () => {
isFinished, isFinished,
user, user,
isAdminRole, isAdminRole,
isTheAdmin, isStudentAdmin,
isTeacherAdmin,
isTeacherOrAbove,
isSuperAdmin, isSuperAdmin,
hasProblemPermission, hasProblemPermission,
isAuthed, isAuthed,

View File

@@ -133,7 +133,8 @@ export const CONTEST_TYPE = {
export const USER_TYPE = { export const USER_TYPE = {
REGULAR_USER: "Regular User", REGULAR_USER: "Regular User",
ADMIN: "Admin", STUDENT_ADMIN: "Student Admin",
TEACHER_ADMIN: "Teacher Admin",
SUPER_ADMIN: "Super Admin", SUPER_ADMIN: "Super Admin",
} }

View File

@@ -133,15 +133,22 @@ export function debounce<T extends (...args: any[]) => any>(
} }
export function getUserRole(role: User["admin_type"]): { export function getUserRole(role: User["admin_type"]): {
type: "default" | "info" | "error" type: "default" | "info" | "warning" | "error"
label: "普通" | "管理员" | "超管" label: "普通" | "学生管理员" | "教师管理员" | "超管"
} { } {
const roleMap = { const roleMap = {
[USER_TYPE.REGULAR_USER]: { [USER_TYPE.REGULAR_USER]: {
type: "default" as const, type: "default" as const,
label: "普通" as const, label: "普通" as const,
}, },
[USER_TYPE.ADMIN]: { type: "info" as const, label: "管理员" as const }, [USER_TYPE.STUDENT_ADMIN]: {
type: "info" as const,
label: "学生管理员" as const,
},
[USER_TYPE.TEACHER_ADMIN]: {
type: "warning" as const,
label: "教师管理员" as const,
},
[USER_TYPE.SUPER_ADMIN]: { [USER_TYPE.SUPER_ADMIN]: {
type: "error" as const, type: "error" as const,
label: "超管" as const, label: "超管" as const,

View File

@@ -1,19 +1,15 @@
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
/**
* 权限检查工具函数
*/
export function usePermissions() { export function usePermissions() {
const userStore = useUserStore() const userStore = useUserStore()
return { return {
// 基本权限检查
isAuthenticated: computed(() => userStore.isAuthed), isAuthenticated: computed(() => userStore.isAuthed),
isAdminRole: computed(() => userStore.isAdminRole), isAdminRole: computed(() => userStore.isAdminRole),
isTeacherOrAbove: computed(() => userStore.isTeacherOrAbove),
isSuperAdmin: computed(() => userStore.isSuperAdmin), isSuperAdmin: computed(() => userStore.isSuperAdmin),
hasProblemPermission: computed(() => userStore.hasProblemPermission), hasProblemPermission: computed(() => userStore.hasProblemPermission),
// 功能权限检查
canManageUsers: computed(() => userStore.isSuperAdmin), canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin), canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin), canManageComments: computed(() => userStore.isSuperAdmin),
@@ -22,9 +18,10 @@ export function usePermissions() {
canSendMessages: computed(() => userStore.isSuperAdmin), canSendMessages: computed(() => userStore.isSuperAdmin),
canManageProblems: computed(() => userStore.hasProblemPermission), canManageProblems: computed(() => userStore.hasProblemPermission),
canManageContests: computed(() => userStore.isSuperAdmin), canManageContests: computed(() => userStore.isTeacherOrAbove),
canManageProblemsets: computed(() => userStore.isTeacherOrAbove),
canViewClassroomData: computed(() => userStore.isTeacherOrAbove),
// 题目权限细分检查
canManageAllProblems: computed( canManageAllProblems: computed(
() => () =>
userStore.user?.problem_permission === "All" || userStore.isSuperAdmin, userStore.user?.problem_permission === "All" || userStore.isSuperAdmin,
@@ -34,17 +31,15 @@ export function usePermissions() {
userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin, userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin,
), ),
// 获取用户权限级别描述
getUserPermissionLevel: computed(() => { getUserPermissionLevel: computed(() => {
if (userStore.isSuperAdmin) return "超级管理员" if (userStore.isSuperAdmin) return "超级管理员"
if (userStore.isAdminRole) return "管理员" if (userStore.isTeacherAdmin) return "教师管理员"
if (userStore.isStudentAdmin) return "学生管理员"
return "普通用户" return "普通用户"
}), }),
// 获取题目权限描述
getProblemPermissionLevel: computed(() => { getProblemPermissionLevel: computed(() => {
if (!userStore.user) return "无权限" if (!userStore.user) return "无权限"
switch (userStore.user.problem_permission) { switch (userStore.user.problem_permission) {
case "All": case "All":
return "管理所有题目" return "管理所有题目"
@@ -59,13 +54,9 @@ export function usePermissions() {
} }
} }
/**
* 路由权限检查
*/
export function checkRoutePermission(routeName: string): boolean { export function checkRoutePermission(routeName: string): boolean {
const userStore = useUserStore() const userStore = useUserStore()
// 需要super admin权限的路由
const superAdminRoutes = [ const superAdminRoutes = [
"admin home", "admin home",
"admin config", "admin config",
@@ -79,35 +70,39 @@ export function checkRoutePermission(routeName: string): boolean {
"admin tutorial list", "admin tutorial list",
"admin tutorial create", "admin tutorial create",
"admin tutorial edit", "admin tutorial edit",
]
const teacherAdminRoutes = [
"admin contest list", "admin contest list",
"admin contest create", "admin contest create",
"admin contest edit", "admin contest edit",
"admin contest problem list", "admin contest problem list",
"admin contest problem create", "admin contest problem create",
"admin contest problem edit", "admin contest problem edit",
"admin contest helper",
"admin problemset list",
"admin problemset create",
"admin problemset edit",
"admin problemset detail",
] ]
// 需要题目权限的路由
const problemPermissionRoutes = [ const problemPermissionRoutes = [
"admin problem list", "admin problem list",
"admin problem create", "admin problem create",
"admin problem edit", "admin problem edit",
] ]
// 需要基本admin权限的路由
const adminRoutes: string[] = ["admin problem list"]
if (superAdminRoutes.includes(routeName)) { if (superAdminRoutes.includes(routeName)) {
return userStore.isSuperAdmin return userStore.isSuperAdmin
} }
if (teacherAdminRoutes.includes(routeName)) {
return userStore.isTeacherOrAbove
}
if (problemPermissionRoutes.includes(routeName)) { if (problemPermissionRoutes.includes(routeName)) {
return userStore.hasProblemPermission return userStore.hasProblemPermission
} }
if (adminRoutes.includes(routeName)) {
return userStore.isAdminRole
}
return true return true
} }

View File

@@ -33,7 +33,7 @@ export interface Profile {
submission_number: number submission_number: number
} }
export type UserAdminType = "Regular User" | "Admin" | "Super Admin" export type UserAdminType = "Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
export interface User { export interface User {
id: number id: number