重构用户权限
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2025-09-25 18:41:32 +08:00
parent 4429e2f018
commit efbca0e802
15 changed files with 619 additions and 187 deletions

123
docs/PERMISSIONS.md Normal file
View File

@@ -0,0 +1,123 @@
# 前端权限控制说明
本文档说明了前端如何根据后端权限设计实现权限控制。
## 权限等级
### 1. 普通用户 (Regular User)
- admin_type: "Regular User"
- problem_permission: "None"
- 只能访问前台功能,无法进入管理后台
### 2. 管理员 (Admin)
- admin_type: "Admin"
- problem_permission: "None" | "Own" | "All"
- 可以访问部分管理功能
### 3. 超级管理员 (Super Admin)
- admin_type: "Super Admin"
- problem_permission: "All" (自动设置)
- 可以访问所有管理功能
## 权限控制实现
### 1. 用户状态管理 (`src/shared/store/user.ts`)
```typescript
const isAdminRole = computed(() =>
user.value?.admin_type === USER_TYPE.ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN
)
const isSuperAdmin = computed(() =>
user.value?.admin_type === USER_TYPE.SUPER_ADMIN
)
const hasProblemPermission = computed(() =>
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE
)
```
### 2. 权限工具函数 (`src/utils/permissions.ts`)
提供了一系列权限检查函数:
- `canManageUsers`: 只有super_admin可以管理用户
- `canManageProblems`: 需要题目权限
- `canManageSystemConfig`: 只有super_admin可以管理系统配置
- 等等...
### 3. 路由权限控制 (`src/main.ts`)
在路由守卫中检查权限:
- `requiresAdmin`: 需要admin或super_admin权限
- `requiresSuperAdmin`: 只有super_admin可以访问
- `requiresProblemPermission`: 需要题目管理权限
### 4. 菜单权限控制 (`src/shared/layout/admin.vue`)
根据用户权限动态显示菜单项:
- admin和super_admin都可以看到题目管理
- 只有super_admin可以看到比赛管理、用户管理、系统设置、公告管理、评论管理、教程管理
## 各功能模块权限说明
### 用户管理 (需要 super_admin)
- 创建、编辑、删除用户
- 重置用户密码
- 封禁/解封用户
- 批量导入用户
### 题目管理 (需要 problem_permission)
- `problem_permission = "None"`: 无法管理题目
- `problem_permission = "Own"`: 只能管理自己创建的题目
- `problem_permission = "All"`: 可以管理所有题目
### 比赛管理 (super_admin)
- 创建、编辑、删除比赛
- 管理比赛题目
- 查看比赛数据
### 系统配置 (需要 super_admin)
- SMTP邮件配置
- 判题服务器管理
- 网站基本设置
- 测试用例清理
### 公告管理 (需要 super_admin)
- 创建、编辑、删除公告
### 评论管理 (需要 super_admin)
- 查看、删除用户评论
### 教程管理 (需要 super_admin)
- 创建、编辑、删除教程
## 权限检查流程
1. **路由级权限检查**: 在 `main.ts` 的路由守卫中进行
2. **组件级权限检查**: 在组件内部使用 `usePermissions()` 进行检查
3. **UI权限控制**: 根据权限动态显示/隐藏UI元素
## 使用示例
```vue
<script setup lang="ts">
import { usePermissions } from "~/utils/permissions"
const { canManageUsers, isSuperAdmin } = usePermissions()
// 权限检查
if (!canManageUsers.value) {
message.error("您没有权限访问此页面")
router.push("/admin")
}
</script>
<template>
<!-- 根据权限显示不同内容 -->
<n-button v-if="isSuperAdmin" type="primary">
超级管理员专用功能
</n-button>
</template>
```
## 注意事项
1. **前端权限控制只是UI层面的限制**,真正的权限控制在后端
2. **所有API调用都会在后端进行权限验证**
3. **权限信息来自后端用户接口**,前端不应该缓存或修改权限信息
4. **路由权限检查是异步的**,需要等待用户信息加载完成

View File

@@ -84,11 +84,11 @@ export function getContestProblem(id: number) {
export function getUserList( export function getUserList(
offset = 0, offset = 0,
limit = 10, limit = 10,
admin = "0", type = "",
keyword: string, keyword: string,
) { ) {
return http.get("admin/user", { return http.get("admin/user", {
params: { paging: true, offset, limit, keyword, admin }, params: { paging: true, offset, limit, keyword, type },
}) })
} }

View File

@@ -2,6 +2,7 @@
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import { parseTime } from "~/utils/functions" import { parseTime } from "~/utils/functions"
import { Server } from "~/utils/types" import { Server } from "~/utils/types"
import { usePermissions } from "~/utils/permissions"
import { import {
deleteJudgeServer, deleteJudgeServer,
editWebsite, editWebsite,
@@ -17,6 +18,14 @@ interface Testcase {
} }
const message = useMessage() const message = useMessage()
const router = useRouter()
const { canManageSystemConfig } = usePermissions()
// 权限检查只有super_admin可以管理系统配置
if (!canManageSystemConfig.value) {
message.error("您没有权限访问此页面")
router.push("/admin")
}
const testcaseColumns: DataTableColumn<Testcase>[] = [ const testcaseColumns: DataTableColumn<Testcase>[] = [
{ title: "测试用例 ID", key: "id" }, { title: "测试用例 ID", key: "id" },

View File

@@ -3,6 +3,7 @@ import { NButton } from "naive-ui"
import { getRank } from "oj/api" import { getRank } from "oj/api"
import Pagination from "~/shared/components/Pagination.vue" import Pagination from "~/shared/components/Pagination.vue"
import { useUserStore } from "~/shared/store/user" import { useUserStore } from "~/shared/store/user"
import { usePermissions } from "~/utils/permissions"
import { getACRate } from "~/utils/functions" import { getACRate } from "~/utils/functions"
import { Rank } from "~/utils/types" import { Rank } from "~/utils/types"
import { getBaseInfo, randomUser10 } from "../api" import { getBaseInfo, randomUser10 } from "../api"
@@ -13,6 +14,13 @@ const contestCount = ref(0)
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const { isSuperAdmin } = usePermissions()
// 权限检查只有super_admin可以访问管理员首页
if (!isSuperAdmin.value) {
message.error("您没有权限访问此页面")
router.push("/admin/problem/list")
}
const showModal = ref(false) const showModal = ref(false)
const luckyGuy = ref("") const luckyGuy = ref("")

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { USER_TYPE } from "~/utils/constants"
import { getUserRole } from "~/utils/functions" import { getUserRole } from "~/utils/functions"
import { User } from "~/utils/types" import { User } from "~/utils/types"
@@ -6,7 +7,9 @@ interface Props {
user: User user: User
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const isAdmin = computed(() => props.user.admin_type !== "Regular User") const isNotRegularUser = computed(
() => props.user.admin_type !== USER_TYPE.REGULAR_USER,
)
</script> </script>
<template> <template>
<n-flex align="center"> <n-flex align="center">
@@ -14,7 +17,7 @@ const isAdmin = computed(() => props.user.admin_type !== "Regular User")
封号中 封号中
</n-tag> </n-tag>
<n-tag <n-tag
v-if="isAdmin" v-if="isNotRegularUser"
:type="getUserRole(props.user.admin_type).type" :type="getUserRole(props.user.admin_type).type"
size="small" size="small"
> >

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { DataTableRowKey, SelectOption } from "naive-ui" import { DataTableRowKey, SelectOption } from "naive-ui"
import Pagination from "~/shared/components/Pagination.vue" import Pagination from "~/shared/components/Pagination.vue"
import { parseTime } from "~/utils/functions" import { parseTime, filterEmptyValue } from "~/utils/functions"
import { User } from "~/utils/types" import { User } from "~/utils/types"
import { usePermissions } from "~/utils/permissions"
import { import {
deleteUsers, deleteUsers,
editUser, editUser,
@@ -15,6 +16,15 @@ import Name from "./components/Name.vue"
import { USER_TYPE } from "~/utils/constants" import { USER_TYPE } from "~/utils/constants"
const message = useMessage() const message = useMessage()
const router = useRouter()
const route = useRoute()
const { canManageUsers } = usePermissions()
// 权限检查只有super_admin可以管理用户
if (!canManageUsers.value) {
message.error("您没有权限访问此页面")
router.push("/admin")
}
const total = ref(0) const total = ref(0)
const users = ref<User[]>([]) const users = ref<User[]>([])
@@ -23,8 +33,14 @@ const query = reactive({
limit: 10, limit: 10,
page: 1, page: 1,
keyword: "", keyword: "",
admin: false, type: "",
}) })
const adminOptions = [
{ label: "全部用户", value: "" },
{ label: "管理员", value: USER_TYPE.ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
]
const [create, toggleCreate] = useToggle(false) const [create, toggleCreate] = useToggle(false)
const password = ref("") const password = ref("")
const userIDs = ref<DataTableRowKey[]>([]) const userIDs = ref<DataTableRowKey[]>([])
@@ -78,15 +94,27 @@ const columns: DataTableColumn<User>[] = [
] ]
const options: SelectOption[] = [ const options: SelectOption[] = [
{ label: "普通", value: "Regular User" }, { label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "管理员", value: "Admin" }, { label: "管理员", value: USER_TYPE.ADMIN },
{ label: "超级管理员", value: "Super Admin" }, { label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
] ]
function routerPush() {
router.push({
path: route.path,
query: filterEmptyValue(query),
})
}
async function listUsers() { async function listUsers() {
query.keyword = <string>route.query.keyword ?? ""
query.page = parseInt(<string>route.query.page) || 1
query.limit = parseInt(<string>route.query.limit) || 10
query.type = <string>route.query.type ?? ""
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
const isAdmin = query.admin ? "1" : "0" const res = await getUserList(offset, query.limit, query.type, query.keyword)
const res = await getUserList(offset, query.limit, isAdmin, query.keyword)
total.value = res.data.total total.value = res.data.total
users.value = res.data.results users.value = res.data.results
} }
@@ -127,7 +155,7 @@ function createNewUser() {
username: "", username: "",
real_name: "", real_name: "",
email: "", email: "",
admin_type: "Regular User", admin_type: "Super Admin",
problem_permission: "", problem_permission: "",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
@@ -177,7 +205,28 @@ async function handleEditUser() {
} }
onMounted(listUsers) onMounted(listUsers)
watch(query, listUsers, { deep: true }) watch(() => query.page, routerPush)
watch(
() => [query.limit, query.keyword, query.type],
() => {
query.page = 1
routerPush()
},
)
watchDebounced(
() => query.keyword,
() => {
query.page = 1
routerPush()
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => route.name === "admin user list" && route.query,
(newVal) => {
if (newVal) listUsers()
},
)
</script> </script>
<template> <template>
@@ -200,8 +249,12 @@ watch(query, listUsers, { deep: true })
确定删除选中的用户吗删除后无法恢复 确定删除选中的用户吗删除后无法恢复
</n-popconfirm> </n-popconfirm>
<n-flex align="center"> <n-flex align="center">
<span>超管出列</span> <n-select
<n-switch v-model:value="query.admin" /> v-model:value="query.type"
:options="adminOptions"
placeholder="选择用户类型"
style="width: 120px"
/>
<div> <div>
<n-input style="width: 200px" v-model:value="query.keyword" /> <n-input style="width: 200px" v-model:value="query.keyword" />
</div> </div>

View File

@@ -31,17 +31,68 @@ const router = createRouter({
routes: [ojs, admins], routes: [ojs, admins],
}) })
router.beforeEach((to, from, next) => { router.beforeEach(async (to, from, next) => {
// 检查是否需要认证
if (to.matched.some((record) => record.meta.requiresAuth)) { if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!storage.get(STORAGE_KEY.AUTHED)) { if (!storage.get(STORAGE_KEY.AUTHED)) {
toggleLogin(true) toggleLogin(true)
next("/") next("/")
} else { return
next()
} }
} else {
next()
} }
// 检查管理员权限
if (to.matched.some((record) =>
record.meta.requiresAdmin ||
record.meta.requiresSuperAdmin ||
record.meta.requiresProblemPermission
)) {
if (!storage.get(STORAGE_KEY.AUTHED)) {
toggleLogin(true)
next("/")
return
}
// 动态导入用户store来检查权限
const { useUserStore } = await import("./shared/store/user")
const userStore = useUserStore()
// 确保用户信息已加载
if (!userStore.user) {
try {
await userStore.getMyProfile()
} catch (error) {
next("/")
return
}
}
// 检查super admin权限
if (to.matched.some((record) => record.meta.requiresSuperAdmin)) {
if (!userStore.isSuperAdmin) {
next("/admin")
return
}
}
// 检查题目权限
else if (to.matched.some((record) => record.meta.requiresProblemPermission)) {
if (!userStore.hasProblemPermission) {
next("/admin")
return
}
}
// 检查基本admin权限
else if (to.matched.some((record) => record.meta.requiresAdmin)) {
if (!userStore.isAdminRole) {
next("/")
return
}
}
}
next()
}) })
ChartJS.register( ChartJS.register(

View File

@@ -70,16 +70,22 @@ async function listSubmissions() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = query.limit * (query.page - 1) const offset = query.limit * (query.page - 1)
const res = await getSubmissions({ try {
...query, const res = await getSubmissions({
myself: query.myself ? "1" : "0", ...query,
offset, myself: query.myself ? "1" : "0",
problem_id: <string>route.query.problem ?? "", offset,
contest_id: <string>route.params.contestID ?? "", problem_id: <string>route.query.problem ?? "",
language: query.language, contest_id: <string>route.params.contestID ?? "",
}) language: query.language,
submissions.value = res.data.results })
total.value = res.data.total submissions.value = res.data.results
total.value = res.data.total
} catch (error: any) {
if (error.data === "Problem doesn't exist") {
message.error("题目不存在")
}
}
} }
async function getTodayCount() { async function getTodayCount() {

View File

@@ -107,113 +107,138 @@ export const admins: RouteRecordRaw = {
path: "", path: "",
name: "admin home", name: "admin home",
component: () => import("~/admin/setting/home.vue"), component: () => import("~/admin/setting/home.vue"),
meta: { requiresAdmin: true },
}, },
// 只有super_admin可以访问的路由
{ {
path: "config", path: "config",
name: "admin config", name: "admin config",
component: () => import("admin/setting/config.vue"), component: () => import("admin/setting/config.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "user/list", path: "user/list",
name: "admin user list", name: "admin user list",
component: () => import("admin/user/list.vue"), component: () => import("admin/user/list.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "user/generate", path: "user/generate",
name: "admin user generate", name: "admin user generate",
component: () => import("~/admin/user/generate.vue"), component: () => import("~/admin/user/generate.vue"),
meta: { requiresSuperAdmin: true },
}, },
// admin和super_admin都可以访问的路由 (需要题目权限)
{ {
path: "problem/list", path: "problem/list",
name: "admin problem list", name: "admin problem list",
component: () => import("admin/problem/list.vue"), component: () => import("admin/problem/list.vue"),
meta: { requiresProblemPermission: true },
}, },
{ {
path: "problem/create", path: "problem/create",
name: "admin problem create", name: "admin problem create",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/detail.vue"),
meta: { requiresProblemPermission: true },
}, },
{ {
path: "problem/edit/:problemID", path: "problem/edit/:problemID",
name: "admin problem edit", name: "admin problem edit",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/detail.vue"),
props: true, props: true,
meta: { requiresProblemPermission: true },
}, },
// admin和super_admin都可以访问的路由
{ {
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: { requiresAdmin: 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: { requiresAdmin: 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: { requiresAdmin: 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: { requiresAdmin: 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: { requiresAdmin: 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: { requiresAdmin: true },
}, },
// 只有super_admin可以访问的路由
{ {
path: "announcement/list", path: "announcement/list",
name: "admin announcement list", name: "admin announcement list",
component: () => import("admin/announcement/list.vue"), component: () => import("admin/announcement/list.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "announcement/create", path: "announcement/create",
name: "admin announcement create", name: "admin announcement create",
component: () => import("admin/announcement/detail.vue"), component: () => import("admin/announcement/detail.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "announcement/edit/:announcementID", path: "announcement/edit/:announcementID",
name: "admin announcement edit", name: "admin announcement edit",
component: () => import("admin/announcement/detail.vue"), component: () => import("admin/announcement/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "comment/list", path: "comment/list",
name: "admin comment list", name: "admin comment list",
component: () => import("admin/communication/comments.vue"), component: () => import("admin/communication/comments.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "message/list", path: "message/list",
name: "admin message list", name: "admin message list",
component: () => import("admin/communication/messages.vue"), component: () => import("admin/communication/messages.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "tutorial/list", path: "tutorial/list",
name: "admin tutorial list", name: "admin tutorial list",
component: () => import("admin/tutorial/list.vue"), component: () => import("admin/tutorial/list.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "tutorial/create", path: "tutorial/create",
name: "admin tutorial create", name: "admin tutorial create",
component: () => import("admin/tutorial/detail.vue"), component: () => import("admin/tutorial/detail.vue"),
meta: { requiresSuperAdmin: true },
}, },
{ {
path: "tutorial/edit/:tutorialID", path: "tutorial/edit/:tutorialID",
name: "admin tutorial edit", name: "admin tutorial edit",
component: () => import("admin/tutorial/detail.vue"), component: () => import("admin/tutorial/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true },
}, },
], ],
} }

View File

@@ -87,7 +87,12 @@ const menus = computed<MenuOption[]>(() => [
icon: renderIcon("streamline-emojis:palm-tree"), icon: renderIcon("streamline-emojis:palm-tree"),
}, },
{ {
label: () => h(RouterLink, { to: "/admin" }, { default: () => "后台" }), label: () =>
h(
RouterLink,
{ to: userStore.isTheAdmin ? "/admin/problem/list" : "/admin" },
{ default: () => (userStore.isTheAdmin ? "我出的题" : "后台") },
),
show: userStore.isAdminRole, show: userStore.isAdminRole,
key: "admin", key: "admin",
icon: renderIcon("streamline-emojis:ghost"), icon: renderIcon("streamline-emojis:ghost"),

View File

@@ -7,55 +7,92 @@ import { useUserStore } from "../store/user"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const options: MenuOption[] = [
{ // 根据用户权限动态生成菜单选项
label: () => h(RouterLink, { to: "/" }, { default: () => "前台" }), const options = computed<MenuOption[]>(() => {
key: "return to OJ", const baseOptions: MenuOption[] = [
}, {
{ label: () => h(RouterLink, { to: "/" }, { default: () => "前台" }),
label: () => h(RouterLink, { to: "/admin" }, { default: () => "管理" }), key: "return to OJ",
key: "admin home", },
}, ]
{
label: () => // admin 可以访问的功能
h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }), if (userStore.isTheAdmin) {
key: "admin config", baseOptions.push({
}, label: () =>
{ h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
label: () => key: "admin problem list",
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }), })
key: "admin problem list", }
},
{ // super_admin 可以访问的功能
label: () => if (userStore.isSuperAdmin) {
h(RouterLink, { to: "/admin/comment/list" }, { default: () => "评论" }), baseOptions.push(
key: "admin comment list", {
}, label: () => h(RouterLink, { to: "/admin" }, { default: () => "管理" }),
{ key: "admin home",
label: () => },
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }), {
key: "admin user list", label: () =>
}, h(
{ RouterLink,
label: () => { to: "/admin/problem/list" },
h(RouterLink, { to: "/admin/contest/list" }, { default: () => "比赛" }), { default: () => "题目" },
key: "admin contest list", ),
}, key: "admin problem list",
{ },
label: () => {
h( label: () =>
RouterLink, h(
{ to: "/admin/announcement/list" }, RouterLink,
{ default: () => "公告" }, { to: "/admin/contest/list" },
), { default: () => "比赛" },
key: "admin announcement list", ),
}, key: "admin contest list",
{ },
label: () => {
h(RouterLink, { to: "/admin/tutorial/list" }, { default: () => "教程" }), label: () =>
key: "admin tutorial list", h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }),
}, key: "admin config",
] },
{
label: () =>
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
),
key: "admin comment list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/announcement/list" },
{ default: () => "公告" },
),
key: "admin announcement list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/tutorial/list" },
{ default: () => "教程" },
),
key: "admin tutorial list",
},
)
}
return baseOptions
})
const active = computed(() => (route.name as string) || "home") const active = computed(() => (route.name as string) || "home")

View File

@@ -13,6 +13,9 @@ export const useUserStore = defineStore("user", () => {
user.value?.admin_type === USER_TYPE.ADMIN || user.value?.admin_type === USER_TYPE.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,
) )
@@ -37,6 +40,7 @@ export const useUserStore = defineStore("user", () => {
isFinished, isFinished,
user, user,
isAdminRole, isAdminRole,
isTheAdmin,
isSuperAdmin, isSuperAdmin,
hasProblemPermission, hasProblemPermission,
isAuthed, isAuthed,

View File

@@ -1,34 +1,30 @@
import { getTime, intervalToDuration, parseISO } from "date-fns" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types" import { User } from "./types"
import { USER_TYPE } from "./constants"
export function getACRate(acCount: number, totalCount: number) { function calculateACRate(acCount: number, totalCount: number): string {
let rate = "" if (totalCount === 0) return "0.00"
if (totalCount === 0) rate = "0.00" if (acCount >= totalCount) return "100.00"
else { return ((acCount / totalCount) * 100).toFixed(2)
if (acCount >= totalCount) rate = "100.00"
else rate = ((acCount / totalCount) * 100).toFixed(2)
}
return `${rate}%`
} }
export function getACRateNumber(acCount: number, totalCount: number) { export function getACRate(acCount: number, totalCount: number): string {
let rate = "" return `${calculateACRate(acCount, totalCount)}%`
if (totalCount === 0) rate = "0.00"
else {
if (acCount >= totalCount) rate = "100.00"
else rate = ((acCount / totalCount) * 100).toFixed(2)
}
return parseFloat(rate)
} }
export function filterEmptyValue(object: any) { export function getACRateNumber(acCount: number, totalCount: number): number {
let query: any = {} return parseFloat(calculateACRate(acCount, totalCount))
Object.keys(object).forEach((key) => { }
if (object[key] || object[key] === 0 || object[key] === false) {
query[key] = object[key] export function filterEmptyValue<T extends Record<string, any>>(
object: T,
): Partial<T> {
return Object.entries(object).reduce((query, [key, value]) => {
if (value != null && value !== "" && value !== undefined) {
query[key as keyof T] = value
} }
}) return query
return query }, {} as Partial<T>)
} }
export function getTagColor( export function getTagColor(
@@ -50,56 +46,52 @@ export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
return time.value return time.value
} }
function getDurationObject(start: Date | string, end: Date | string) {
return intervalToDuration({
start: getTime(parseISO(start.toString())),
end: getTime(parseISO(end.toString())),
})
}
function formatDurationUnits(
duration: Duration,
units: Array<{ key: keyof Duration; suffix: string }>,
): string {
return units
.filter(({ key }) => duration[key])
.map(({ key, suffix }) => duration[key] + suffix)
.join("")
}
export function duration( export function duration(
start: Date | string, start: Date | string,
end: Date | string, end: Date | string,
showSeconds = false, showSeconds = false,
): string { ): string {
const duration = intervalToDuration({ const durationObj = getDurationObject(start, end)
start: getTime(parseISO(start.toString())), const units = [
end: getTime(parseISO(end.toString())), { key: "years" as const, suffix: "年" },
}) { key: "months" as const, suffix: "月" },
let result = "" { key: "days" as const, suffix: "" },
if (duration.years) { { key: "hours" as const, suffix: "小时" },
result += duration.years + "年" { key: "minutes" as const, suffix: "分钟" },
} ...(showSeconds ? [{ key: "seconds" as const, suffix: "秒" }] : []),
if (duration.months) { ]
result += duration.months + "月" return formatDurationUnits(durationObj, units)
}
if (duration.days) {
result += duration.days + "天"
}
if (duration.hours) {
result += duration.hours + "小时"
}
if (duration.minutes) {
result += duration.minutes + "分钟"
}
if (showSeconds && duration.seconds) {
result += duration.seconds + "秒"
}
return result
} }
export function durationToDays( export function durationToDays(
start: Date | string, start: Date | string,
end: Date | string, end: Date | string,
): string { ): string {
const duration = intervalToDuration({ const durationObj = getDurationObject(start, end)
start: getTime(parseISO(start.toString())), const units = [
end: getTime(parseISO(end.toString())), { key: "years" as const, suffix: "年" },
}) { key: "months" as const, suffix: "月" },
let result = "" { key: "days" as const, suffix: "" },
if (duration.years) { ]
result += duration.years + "年" const result = formatDurationUnits(durationObj, units)
} return result || "一天以内"
if (duration.months) {
result += duration.months + "月"
}
if (duration.days) {
result += duration.days + "天"
}
return !!result ? result : "一天以内"
} }
export function secondsToDuration(seconds: number): string { export function secondsToDuration(seconds: number): string {
@@ -126,13 +118,14 @@ export function submissionTimeFormat(time: number | string | undefined) {
return time + "ms" return time + "ms"
} }
export function debounce(fn: Function, n = 100) { export function debounce<T extends (...args: any[]) => any>(
let handle: any fn: T,
return (...args: any[]) => { delay = 100,
if (handle) clearTimeout(handle) ): (...args: Parameters<T>) => void {
handle = setTimeout(() => { let timeoutId: ReturnType<typeof setTimeout>
fn(...args) return (...args: Parameters<T>) => {
}, n) clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
} }
} }
@@ -140,50 +133,50 @@ export function getUserRole(role: User["admin_type"]): {
type: "default" | "info" | "error" type: "default" | "info" | "error"
tagString: "普通" | "管理员" | "超管" tagString: "普通" | "管理员" | "超管"
} { } {
const obj: { const roleMap = {
type: "default" | "info" | "error" [USER_TYPE.REGULAR_USER]: {
tagString: "普通" | "管理员" | "超管" type: "default" as const,
} = { type: "default", tagString: "普通" } tagString: "普通" as const,
switch (role) { },
case "Regular User": [USER_TYPE.ADMIN]: { type: "info" as const, tagString: "管理员" as const },
obj.type = "default" [USER_TYPE.SUPER_ADMIN]: {
obj.tagString = "普通" type: "error" as const,
break tagString: "超管" as const,
case "Admin": },
obj.type = "info"
obj.tagString = "管理员"
break
case "Super Admin":
obj.type = "error"
obj.tagString = "超管"
break
} }
return obj
return roleMap[role] || roleMap[USER_TYPE.REGULAR_USER]
} }
export function unique<T>(arr: T[]) { export function unique<T>(arr: T[]): T[] {
return arr.reduce((prev: T[], curr: T) => { return [...new Set(arr)]
if (!prev.includes(curr)) {
prev.push(curr)
}
return prev
}, [])
} }
export function encode(string?: string) { export function encode(string?: string): string {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? ""))) try {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
} catch (error) {
console.error("编码失败:", error)
return ""
}
} }
export function decode(bytes?: string) { export function decode(bytes?: string): string {
const latin = atob(bytes ?? "") try {
return new TextDecoder("utf-8").decode( if (!bytes) return ""
Uint8Array.from({ length: latin.length }, (_, index) => const latin = atob(bytes)
latin.charCodeAt(index), return new TextDecoder("utf-8").decode(
), Uint8Array.from({ length: latin.length }, (_, index) =>
) latin.charCodeAt(index),
),
)
} catch (error) {
console.error("解码失败:", error)
return ""
}
} }
export function getCSRFToken() { export function getCSRFToken(): string {
if (typeof document === "undefined") { if (typeof document === "undefined") {
return "" return ""
} }

113
src/utils/permissions.ts Normal file
View File

@@ -0,0 +1,113 @@
import { useUserStore } from "~/shared/store/user"
/**
* 权限检查工具函数
*/
export function usePermissions() {
const userStore = useUserStore()
return {
// 基本权限检查
isAuthenticated: computed(() => userStore.isAuthed),
isAdminRole: computed(() => userStore.isAdminRole),
isSuperAdmin: computed(() => userStore.isSuperAdmin),
hasProblemPermission: computed(() => userStore.hasProblemPermission),
// 功能权限检查
canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin),
canManageProblems: computed(() => userStore.hasProblemPermission),
canManageContests: computed(() => userStore.isSuperAdmin),
// 题目权限细分检查
canManageAllProblems: computed(
() =>
userStore.user?.problem_permission === "All" || userStore.isSuperAdmin,
),
canManageOwnProblems: computed(
() =>
userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin,
),
// 获取用户权限级别描述
getUserPermissionLevel: computed(() => {
if (userStore.isSuperAdmin) return "超级管理员"
if (userStore.isAdminRole) return "管理员"
return "普通用户"
}),
// 获取题目权限描述
getProblemPermissionLevel: computed(() => {
if (!userStore.user) return "无权限"
switch (userStore.user.problem_permission) {
case "All":
return "管理所有题目"
case "Own":
return "管理自己的题目"
case "None":
return "无题目权限"
default:
return "无权限"
}
}),
}
}
/**
* 路由权限检查
*/
export function checkRoutePermission(routeName: string): boolean {
const userStore = useUserStore()
// 需要super admin权限的路由
const superAdminRoutes = [
"admin home",
"admin config",
"admin user list",
"admin user generate",
"admin announcement list",
"admin announcement create",
"admin announcement edit",
"admin comment list",
"admin message list",
"admin tutorial list",
"admin tutorial create",
"admin tutorial edit",
"admin contest list",
"admin contest create",
"admin contest edit",
"admin contest problem list",
"admin contest problem create",
"admin contest problem edit",
]
// 需要题目权限的路由
const problemPermissionRoutes = [
"admin problem list",
"admin problem create",
"admin problem edit",
]
// 需要基本admin权限的路由
const adminRoutes: string[] = ["admin problem list"]
if (superAdminRoutes.includes(routeName)) {
return userStore.isSuperAdmin
}
if (problemPermissionRoutes.includes(routeName)) {
return userStore.hasProblemPermission
}
if (adminRoutes.includes(routeName)) {
return userStore.isAdminRole
}
return true
}

View File

@@ -1,4 +1,4 @@
import { ContestStatus, ContestType } from "./constants" import { ContestStatus, ContestType, USER_TYPE } from "./constants"
export interface Profile { export interface Profile {
id: number id: number
@@ -33,12 +33,14 @@ export interface Profile {
submission_number: number submission_number: number
} }
export type UserAdminType = "Regular User" | "Admin" | "Super Admin"
export interface User { export interface User {
id: number id: number
username: string username: string
real_name: string real_name: string
email: string email: string
admin_type: "Regular User" | "Super Admin" | "Admin" admin_type: UserAdminType
problem_permission: string problem_permission: string
create_time: Date create_time: Date
last_login: Date last_login: Date