123
docs/PERMISSIONS.md
Normal file
123
docs/PERMISSIONS.md
Normal 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. **路由权限检查是异步的**,需要等待用户信息加载完成
|
||||
@@ -84,11 +84,11 @@ export function getContestProblem(id: number) {
|
||||
export function getUserList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
admin = "0",
|
||||
type = "",
|
||||
keyword: string,
|
||||
) {
|
||||
return http.get("admin/user", {
|
||||
params: { paging: true, offset, limit, keyword, admin },
|
||||
params: { paging: true, offset, limit, keyword, type },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Server } from "~/utils/types"
|
||||
import { usePermissions } from "~/utils/permissions"
|
||||
import {
|
||||
deleteJudgeServer,
|
||||
editWebsite,
|
||||
@@ -17,6 +18,14 @@ interface Testcase {
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const { canManageSystemConfig } = usePermissions()
|
||||
|
||||
// 权限检查:只有super_admin可以管理系统配置
|
||||
if (!canManageSystemConfig.value) {
|
||||
message.error("您没有权限访问此页面")
|
||||
router.push("/admin")
|
||||
}
|
||||
|
||||
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
||||
{ title: "测试用例 ID", key: "id" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NButton } from "naive-ui"
|
||||
import { getRank } from "oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { usePermissions } from "~/utils/permissions"
|
||||
import { getACRate } from "~/utils/functions"
|
||||
import { Rank } from "~/utils/types"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
@@ -13,6 +14,13 @@ const contestCount = ref(0)
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const { isSuperAdmin } = usePermissions()
|
||||
|
||||
// 权限检查:只有super_admin可以访问管理员首页
|
||||
if (!isSuperAdmin.value) {
|
||||
message.error("您没有权限访问此页面")
|
||||
router.push("/admin/problem/list")
|
||||
}
|
||||
|
||||
const showModal = ref(false)
|
||||
const luckyGuy = ref("")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { USER_TYPE } from "~/utils/constants"
|
||||
import { getUserRole } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
|
||||
@@ -6,7 +7,9 @@ interface Props {
|
||||
user: User
|
||||
}
|
||||
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>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
@@ -14,7 +17,7 @@ const isAdmin = computed(() => props.user.admin_type !== "Regular User")
|
||||
封号中
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="isAdmin"
|
||||
v-if="isNotRegularUser"
|
||||
:type="getUserRole(props.user.admin_type).type"
|
||||
size="small"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { parseTime, filterEmptyValue } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import { usePermissions } from "~/utils/permissions"
|
||||
import {
|
||||
deleteUsers,
|
||||
editUser,
|
||||
@@ -15,6 +16,15 @@ import Name from "./components/Name.vue"
|
||||
import { USER_TYPE } from "~/utils/constants"
|
||||
|
||||
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 users = ref<User[]>([])
|
||||
@@ -23,8 +33,14 @@ const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
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 password = ref("")
|
||||
const userIDs = ref<DataTableRowKey[]>([])
|
||||
@@ -78,15 +94,27 @@ const columns: DataTableColumn<User>[] = [
|
||||
]
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "普通", value: "Regular User" },
|
||||
{ label: "管理员", value: "Admin" },
|
||||
{ label: "超级管理员", value: "Super Admin" },
|
||||
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
function routerPush() {
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: filterEmptyValue(query),
|
||||
})
|
||||
}
|
||||
|
||||
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 isAdmin = query.admin ? "1" : "0"
|
||||
const res = await getUserList(offset, query.limit, isAdmin, query.keyword)
|
||||
const res = await getUserList(offset, query.limit, query.type, query.keyword)
|
||||
total.value = res.data.total
|
||||
users.value = res.data.results
|
||||
}
|
||||
@@ -127,7 +155,7 @@ function createNewUser() {
|
||||
username: "",
|
||||
real_name: "",
|
||||
email: "",
|
||||
admin_type: "Regular User",
|
||||
admin_type: "Super Admin",
|
||||
problem_permission: "",
|
||||
create_time: new Date(),
|
||||
last_login: new Date(),
|
||||
@@ -177,7 +205,28 @@ async function handleEditUser() {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -200,8 +249,12 @@ watch(query, listUsers, { deep: true })
|
||||
确定删除选中的用户吗?删除后无法恢复!
|
||||
</n-popconfirm>
|
||||
<n-flex align="center">
|
||||
<span>超管出列</span>
|
||||
<n-switch v-model:value="query.admin" />
|
||||
<n-select
|
||||
v-model:value="query.type"
|
||||
:options="adminOptions"
|
||||
placeholder="选择用户类型"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<div>
|
||||
<n-input style="width: 200px" v-model:value="query.keyword" />
|
||||
</div>
|
||||
|
||||
61
src/main.ts
61
src/main.ts
@@ -31,17 +31,68 @@ const router = createRouter({
|
||||
routes: [ojs, admins],
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 检查是否需要认证
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
toggleLogin(true)
|
||||
next("/")
|
||||
} else {
|
||||
next()
|
||||
return
|
||||
}
|
||||
} 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(
|
||||
|
||||
@@ -70,6 +70,7 @@ async function listSubmissions() {
|
||||
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = query.limit * (query.page - 1)
|
||||
try {
|
||||
const res = await getSubmissions({
|
||||
...query,
|
||||
myself: query.myself ? "1" : "0",
|
||||
@@ -80,6 +81,11 @@ async function listSubmissions() {
|
||||
})
|
||||
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() {
|
||||
|
||||
@@ -107,113 +107,138 @@ export const admins: RouteRecordRaw = {
|
||||
path: "",
|
||||
name: "admin home",
|
||||
component: () => import("~/admin/setting/home.vue"),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
// 只有super_admin可以访问的路由
|
||||
{
|
||||
path: "config",
|
||||
name: "admin config",
|
||||
component: () => import("admin/setting/config.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "user/list",
|
||||
name: "admin user list",
|
||||
component: () => import("admin/user/list.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "user/generate",
|
||||
name: "admin user generate",
|
||||
component: () => import("~/admin/user/generate.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
// admin和super_admin都可以访问的路由 (需要题目权限)
|
||||
{
|
||||
path: "problem/list",
|
||||
name: "admin problem list",
|
||||
component: () => import("admin/problem/list.vue"),
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
{
|
||||
path: "problem/create",
|
||||
name: "admin problem create",
|
||||
component: () => import("admin/problem/detail.vue"),
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
{
|
||||
path: "problem/edit/:problemID",
|
||||
name: "admin problem edit",
|
||||
component: () => import("admin/problem/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
// admin和super_admin都可以访问的路由
|
||||
{
|
||||
path: "contest/list",
|
||||
name: "admin contest list",
|
||||
component: () => import("admin/contest/list.vue"),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/create",
|
||||
name: "admin contest create",
|
||||
component: () => import("admin/contest/detail.vue"),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/edit/:contestID",
|
||||
name: "admin contest edit",
|
||||
component: () => import("admin/contest/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/problem/list",
|
||||
name: "admin contest problem list",
|
||||
component: () => import("admin/problem/list.vue"),
|
||||
props: true,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/problem/create",
|
||||
name: "admin contest problem create",
|
||||
component: () => import("admin/problem/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/problem/edit/:problemID",
|
||||
name: "admin contest problem edit",
|
||||
component: () => import("admin/problem/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
// 只有super_admin可以访问的路由
|
||||
{
|
||||
path: "announcement/list",
|
||||
name: "admin announcement list",
|
||||
component: () => import("admin/announcement/list.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "announcement/create",
|
||||
name: "admin announcement create",
|
||||
component: () => import("admin/announcement/detail.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "announcement/edit/:announcementID",
|
||||
name: "admin announcement edit",
|
||||
component: () => import("admin/announcement/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "comment/list",
|
||||
name: "admin comment list",
|
||||
component: () => import("admin/communication/comments.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "message/list",
|
||||
name: "admin message list",
|
||||
component: () => import("admin/communication/messages.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "tutorial/list",
|
||||
name: "admin tutorial list",
|
||||
component: () => import("admin/tutorial/list.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "tutorial/create",
|
||||
name: "admin tutorial create",
|
||||
component: () => import("admin/tutorial/detail.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "tutorial/edit/:tutorialID",
|
||||
name: "admin tutorial edit",
|
||||
component: () => import("admin/tutorial/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@ const menus = computed<MenuOption[]>(() => [
|
||||
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,
|
||||
key: "admin",
|
||||
icon: renderIcon("streamline-emojis:ghost"),
|
||||
|
||||
@@ -7,30 +7,55 @@ import { useUserStore } from "../store/user"
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const options: MenuOption[] = [
|
||||
|
||||
// 根据用户权限动态生成菜单选项
|
||||
const options = computed<MenuOption[]>(() => {
|
||||
const baseOptions: MenuOption[] = [
|
||||
{
|
||||
label: () => h(RouterLink, { to: "/" }, { default: () => "前台" }),
|
||||
key: "return to OJ",
|
||||
},
|
||||
]
|
||||
|
||||
// admin 可以访问的功能
|
||||
if (userStore.isTheAdmin) {
|
||||
baseOptions.push({
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
|
||||
key: "admin problem list",
|
||||
})
|
||||
}
|
||||
|
||||
// super_admin 可以访问的功能
|
||||
if (userStore.isSuperAdmin) {
|
||||
baseOptions.push(
|
||||
{
|
||||
label: () => h(RouterLink, { to: "/admin" }, { default: () => "管理" }),
|
||||
key: "admin home",
|
||||
},
|
||||
{
|
||||
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/config" }, { default: () => "设置" }),
|
||||
key: "admin config",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
|
||||
key: "admin problem list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/comment/list" }, { default: () => "评论" }),
|
||||
key: "admin comment list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
|
||||
@@ -38,8 +63,12 @@ const options: MenuOption[] = [
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/contest/list" }, { default: () => "比赛" }),
|
||||
key: "admin contest list",
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/comment/list" },
|
||||
{ default: () => "评论" },
|
||||
),
|
||||
key: "admin comment list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
@@ -52,10 +81,18 @@ const options: MenuOption[] = [
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/tutorial/list" }, { default: () => "教程" }),
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/tutorial/list" },
|
||||
{ default: () => "教程" },
|
||||
),
|
||||
key: "admin tutorial list",
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
})
|
||||
|
||||
const active = computed(() => (route.name as string) || "home")
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ export const useUserStore = defineStore("user", () => {
|
||||
user.value?.admin_type === USER_TYPE.ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
const isTheAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.ADMIN,
|
||||
)
|
||||
const isSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
@@ -37,6 +40,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
isFinished,
|
||||
user,
|
||||
isAdminRole,
|
||||
isTheAdmin,
|
||||
isSuperAdmin,
|
||||
hasProblemPermission,
|
||||
isAuthed,
|
||||
|
||||
@@ -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_TYPE } from "./constants"
|
||||
|
||||
export function getACRate(acCount: number, totalCount: number) {
|
||||
let rate = ""
|
||||
if (totalCount === 0) rate = "0.00"
|
||||
else {
|
||||
if (acCount >= totalCount) rate = "100.00"
|
||||
else rate = ((acCount / totalCount) * 100).toFixed(2)
|
||||
}
|
||||
return `${rate}%`
|
||||
function calculateACRate(acCount: number, totalCount: number): string {
|
||||
if (totalCount === 0) return "0.00"
|
||||
if (acCount >= totalCount) return "100.00"
|
||||
return ((acCount / totalCount) * 100).toFixed(2)
|
||||
}
|
||||
|
||||
export function getACRateNumber(acCount: number, totalCount: number) {
|
||||
let rate = ""
|
||||
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 getACRate(acCount: number, totalCount: number): string {
|
||||
return `${calculateACRate(acCount, totalCount)}%`
|
||||
}
|
||||
|
||||
export function filterEmptyValue(object: any) {
|
||||
let query: any = {}
|
||||
Object.keys(object).forEach((key) => {
|
||||
if (object[key] || object[key] === 0 || object[key] === false) {
|
||||
query[key] = object[key]
|
||||
export function getACRateNumber(acCount: number, totalCount: number): number {
|
||||
return parseFloat(calculateACRate(acCount, totalCount))
|
||||
}
|
||||
|
||||
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
|
||||
}, {} as Partial<T>)
|
||||
}
|
||||
|
||||
export function getTagColor(
|
||||
@@ -50,56 +46,52 @@ export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
|
||||
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(
|
||||
start: Date | string,
|
||||
end: Date | string,
|
||||
showSeconds = false,
|
||||
): string {
|
||||
const duration = intervalToDuration({
|
||||
start: getTime(parseISO(start.toString())),
|
||||
end: getTime(parseISO(end.toString())),
|
||||
})
|
||||
let result = ""
|
||||
if (duration.years) {
|
||||
result += duration.years + "年"
|
||||
}
|
||||
if (duration.months) {
|
||||
result += duration.months + "月"
|
||||
}
|
||||
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
|
||||
const durationObj = getDurationObject(start, end)
|
||||
const units = [
|
||||
{ key: "years" as const, suffix: "年" },
|
||||
{ key: "months" as const, suffix: "月" },
|
||||
{ key: "days" as const, suffix: "天" },
|
||||
{ key: "hours" as const, suffix: "小时" },
|
||||
{ key: "minutes" as const, suffix: "分钟" },
|
||||
...(showSeconds ? [{ key: "seconds" as const, suffix: "秒" }] : []),
|
||||
]
|
||||
return formatDurationUnits(durationObj, units)
|
||||
}
|
||||
|
||||
export function durationToDays(
|
||||
start: Date | string,
|
||||
end: Date | string,
|
||||
): string {
|
||||
const duration = intervalToDuration({
|
||||
start: getTime(parseISO(start.toString())),
|
||||
end: getTime(parseISO(end.toString())),
|
||||
})
|
||||
let result = ""
|
||||
if (duration.years) {
|
||||
result += duration.years + "年"
|
||||
}
|
||||
if (duration.months) {
|
||||
result += duration.months + "月"
|
||||
}
|
||||
if (duration.days) {
|
||||
result += duration.days + "天"
|
||||
}
|
||||
return !!result ? result : "一天以内"
|
||||
const durationObj = getDurationObject(start, end)
|
||||
const units = [
|
||||
{ key: "years" as const, suffix: "年" },
|
||||
{ key: "months" as const, suffix: "月" },
|
||||
{ key: "days" as const, suffix: "天" },
|
||||
]
|
||||
const result = formatDurationUnits(durationObj, units)
|
||||
return result || "一天以内"
|
||||
}
|
||||
|
||||
export function secondsToDuration(seconds: number): string {
|
||||
@@ -126,13 +118,14 @@ export function submissionTimeFormat(time: number | string | undefined) {
|
||||
return time + "ms"
|
||||
}
|
||||
|
||||
export function debounce(fn: Function, n = 100) {
|
||||
let handle: any
|
||||
return (...args: any[]) => {
|
||||
if (handle) clearTimeout(handle)
|
||||
handle = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, n)
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay = 100,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => fn(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,50 +133,50 @@ export function getUserRole(role: User["admin_type"]): {
|
||||
type: "default" | "info" | "error"
|
||||
tagString: "普通" | "管理员" | "超管"
|
||||
} {
|
||||
const obj: {
|
||||
type: "default" | "info" | "error"
|
||||
tagString: "普通" | "管理员" | "超管"
|
||||
} = { type: "default", tagString: "普通" }
|
||||
switch (role) {
|
||||
case "Regular User":
|
||||
obj.type = "default"
|
||||
obj.tagString = "普通"
|
||||
break
|
||||
case "Admin":
|
||||
obj.type = "info"
|
||||
obj.tagString = "管理员"
|
||||
break
|
||||
case "Super Admin":
|
||||
obj.type = "error"
|
||||
obj.tagString = "超管"
|
||||
break
|
||||
}
|
||||
return obj
|
||||
const roleMap = {
|
||||
[USER_TYPE.REGULAR_USER]: {
|
||||
type: "default" as const,
|
||||
tagString: "普通" as const,
|
||||
},
|
||||
[USER_TYPE.ADMIN]: { type: "info" as const, tagString: "管理员" as const },
|
||||
[USER_TYPE.SUPER_ADMIN]: {
|
||||
type: "error" as const,
|
||||
tagString: "超管" as const,
|
||||
},
|
||||
}
|
||||
|
||||
export function unique<T>(arr: T[]) {
|
||||
return arr.reduce((prev: T[], curr: T) => {
|
||||
if (!prev.includes(curr)) {
|
||||
prev.push(curr)
|
||||
}
|
||||
return prev
|
||||
}, [])
|
||||
return roleMap[role] || roleMap[USER_TYPE.REGULAR_USER]
|
||||
}
|
||||
|
||||
export function encode(string?: string) {
|
||||
export function unique<T>(arr: T[]): T[] {
|
||||
return [...new Set(arr)]
|
||||
}
|
||||
|
||||
export function encode(string?: string): string {
|
||||
try {
|
||||
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
|
||||
} catch (error) {
|
||||
console.error("编码失败:", error)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function decode(bytes?: string) {
|
||||
const latin = atob(bytes ?? "")
|
||||
export function decode(bytes?: string): string {
|
||||
try {
|
||||
if (!bytes) return ""
|
||||
const latin = atob(bytes)
|
||||
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") {
|
||||
return ""
|
||||
}
|
||||
|
||||
113
src/utils/permissions.ts
Normal file
113
src/utils/permissions.ts
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ContestStatus, ContestType } from "./constants"
|
||||
import { ContestStatus, ContestType, USER_TYPE } from "./constants"
|
||||
|
||||
export interface Profile {
|
||||
id: number
|
||||
@@ -33,12 +33,14 @@ export interface Profile {
|
||||
submission_number: number
|
||||
}
|
||||
|
||||
export type UserAdminType = "Regular User" | "Admin" | "Super Admin"
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string
|
||||
email: string
|
||||
admin_type: "Regular User" | "Super Admin" | "Admin"
|
||||
admin_type: UserAdminType
|
||||
problem_permission: string
|
||||
create_time: Date
|
||||
last_login: Date
|
||||
|
||||
Reference in New Issue
Block a user