feat(后台): 侧栏分组带图标、可收起,加顶栏;所有后台页面统一页头
Deploy / deploy (push) Canceled after 0s

- 侧栏按 概览 / 教学内容 / 学情分析 / 站点管理 分组,去掉超管菜单里的重复项;
  菜单项的角色与路由守卫一一对应,没有题目权限的教师不再看到点了就被踢回首页的「题目」
- 顶栏:面包屑、回到前台、明暗切换、用户菜单(退出登录);窄屏侧栏自动收起
- 新增 PageHeader:标题 + 右侧主操作 + 下方筛选栏,编辑页带返回按钮
- 卡点分析 / 年度趋势 / 标签管理从题目列表的按钮挪进侧栏
- 首页改成统计卡片 + 班级排名卡片;设置页按钮移到卡片右上角
- 题目、比赛、题单、用户列表的「选项」列固定在右侧,不再被挤出屏幕

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 19:54:42 +08:00
1 parent d25b5dbe6d
commit d3823430f9
25 files changed
+643 -466

No files matched your search

+7 -7
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex } from "naive-ui" import { NButton, NFlex } from "naive-ui"
import { deleteAchievement, getAdminAchievements, type AdminAchievement } from "admin/api" import { deleteAchievement, getAdminAchievements, type AdminAchievement } from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
@@ -106,14 +107,14 @@ onMounted(load)
</script> </script>
<template> <template>
<n-card :title="title"> <PageHeader
<template #header-extra> :title="title"
description="「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。"
>
<template #actions>
<n-button type="primary" @click="create">新建成就</n-button> <n-button type="primary" @click="create">新建成就</n-button>
</template> </template>
</PageHeader>
<n-alert type="info" style="margin-bottom: 12px">
「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。
</n-alert>
<n-data-table <n-data-table
:loading="loading" :loading="loading"
@@ -123,5 +124,4 @@ onMounted(load)
/> />
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" /> <AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
</n-card>
</template> </template>
+6 -11
View File
@@ -1,13 +1,14 @@
<template> <template>
<n-flex justify="space-between" class="titleWrapper"> <PageHeader title="AI 学习分析报告">
<h2 class="title">AI 学习分析报告</h2> <template #filters>
<n-input <n-input
v-model:value="query.username" v-model:value="query.username"
clearable clearable
placeholder="输入用户名筛选" placeholder="输入用户名筛选"
style="width: 200px" style="width: 220px"
/> />
</n-flex> </template>
</PageHeader>
<n-alert <n-alert
v-if="pinnedReports.length > 0" v-if="pinnedReports.length > 0"
type="warning" type="warning"
@@ -60,6 +61,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import PageHeader from "admin/components/PageHeader.vue"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
@@ -168,13 +170,6 @@ watchDebounced(() => query.username, listReports, {
</script> </script>
<style scoped> <style scoped>
.titleWrapper {
margin-bottom: 16px;
align-items: center;
}
.title {
margin: 0;
}
.detail .meta { .detail .meta {
margin-bottom: 0; margin-bottom: 0;
} }
+5 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import type { AnnouncementEdit } from "utils/types" import type { AnnouncementEdit } from "utils/types"
@@ -72,9 +73,10 @@ async function submit() {
onMounted(init) onMounted(init)
</script> </script>
<template> <template>
<h2 class="title"> <PageHeader
{{ route.name === "admin announcement create" ? "新建公告" : "编辑公告" }} :title="route.name === 'admin announcement create' ? '新建公告' : '编辑公告'"
</h2> :back="{ name: 'admin announcement list' }"
/>
<n-form inline> <n-form inline>
<n-form-item label="标题"> <n-form-item label="标题">
<n-input class="contestTitle" v-model:value="announcement.title" /> <n-input class="contestTitle" v-model:value="announcement.title" />
@@ -95,10 +97,6 @@ onMounted(init)
</n-flex> </n-flex>
</template> </template>
<style scoped> <style scoped>
.title {
margin-top: 0;
}
.select { .select {
width: 100px; width: 100px;
} }
+6 -14
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { NSwitch } from "naive-ui" import { NSwitch } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
@@ -93,22 +94,13 @@ watch(query, listAnnouncements, { deep: true })
</script> </script>
<template> <template>
<n-flex align="center" class="titleWrapper"> <PageHeader title="网站公告">
<h2 class="title">网站公告</h2> <template #actions>
<n-button type="primary" @click="$router.push({ name: 'admin announcement create' })"> <n-button type="primary" @click="$router.push({ name: 'admin announcement create' })">
新建 新建公告
</n-button> </n-button>
</n-flex> </template>
</PageHeader>
<n-data-table striped :columns="columns" :data="announcements" /> <n-data-table striped :columns="columns" :data="announcements" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { RouteLocationRaw } from "vue-router"
/**
* 后台每一页顶上的那一块:标题行(返回 · 标题 · 标签 ········ 操作按钮),
* 下面可选一行筛选栏。以前每页各写一份 `.titleWrapper`,筛选控件和「新建」
* 挤在同一行、左右位置每页不一样,这里统一成一种排法。
*
* - `back`:给了就在标题前出一个返回按钮,跳到这个位置(不用 router.back,
* 从外链直接打开编辑页时没有「上一页」可回)
* - `#extra`:紧跟标题的小东西(计数 tag 之类)
* - `#actions`:标题行最右边的按钮,主操作放最后
* - `#filters`:筛选栏,靠左排
*/
defineProps<{
title: string
description?: string
back?: RouteLocationRaw
}>()
const slots = defineSlots<{
extra?: () => any
actions?: () => any
filters?: () => any
}>()
</script>
<template>
<div class="pageHeader">
<div class="row">
<div class="heading">
<n-button v-if="back" quaternary circle size="small" @click="$router.push(back)">
<template #icon><Icon icon="ph:arrow-left" /></template>
</n-button>
<h2 class="title">{{ title }}</h2>
<slot name="extra" />
</div>
<div v-if="slots.actions" class="actions">
<slot name="actions" />
</div>
</div>
<n-text v-if="description" depth="3" class="description">{{ description }}</n-text>
<div v-if="slots.filters" class="filters">
<slot name="filters" />
</div>
</div>
</template>
<style scoped>
.pageHeader {
margin-bottom: 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
min-height: 34px;
}
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 1.4;
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.description {
display: block;
margin-top: 4px;
font-size: 13px;
}
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 12px;
}
</style>
+13 -26
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { formatISO } from "date-fns" import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
@@ -112,23 +113,19 @@ onMounted(getContestDetail)
</script> </script>
<template> <template>
<n-flex class="titleWrapper" align="center"> <PageHeader
<h2 class="title"> :title="route.name === 'admin contest create' ? '新建比赛' : '编辑比赛'"
{{ route.name === "admin contest create" ? "新建比赛" : "编辑比赛" }} :back="{ name: 'admin contest list' }"
</h2> >
<template v-if="!props.contestID"> <template v-if="!props.contestID" #extra>
<n-alert type="success"> <n-tag type="success" round>
<template #header> 开始 {{ parseTime(contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
开始时间 {{ parseTime(contest.startTime, "YYYY年M月D日 HH:mm:ss") }} </n-tag>
<n-tag type="warning" round>
结束 {{ parseTime(contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</n-tag>
</template> </template>
</n-alert> </PageHeader>
<n-alert type="warning">
<template #header>
结束时间 {{ parseTime(contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</template>
</n-alert>
</template>
</n-flex>
<n-form inline> <n-form inline>
<n-form-item label="标题"> <n-form-item label="标题">
<n-input style="width: 300px" v-model:value="contest.title" /> <n-input style="width: 300px" v-model:value="contest.title" />
@@ -164,13 +161,3 @@ onMounted(getContestDetail)
<n-button type="primary" @click="submit">保存</n-button> <n-button type="primary" @click="submit">保存</n-button>
</n-flex> </n-flex>
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+16 -16
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types" import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types"
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui" import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
@@ -232,24 +233,22 @@ onMounted(loadData)
<template> <template>
<n-flex vertical> <n-flex vertical>
<n-flex justify="space-between" align="center"> <PageHeader
<n-flex align="center"> title="比赛辅助检查"
<h2 style="margin: 0">比赛辅助检查</h2> description="赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选「已检查」。"
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag> :back="{ name: 'admin contest list' }"
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag> >
<n-tag type="warning" size="large"> 未检查: {{ stats.unchecked }} </n-tag> <template #extra>
</n-flex> <n-tag type="info" round>总计 {{ stats.total }}</n-tag>
<n-tag type="success" round>已检查 {{ stats.checked }}</n-tag>
<n-tag type="warning" round>未检查 {{ stats.unchecked }}</n-tag>
</template>
<template #actions>
<n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked"> <n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked">
标记全部为已检查 标记全部为已检查
</n-button> </n-button>
</n-flex> </template>
<template #filters>
<n-alert type="info" style="margin-bottom: 16px">
<template #header>使用说明</template>
此工具用于赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选"已检查"。
</n-alert>
<n-flex align="center" style="margin-bottom: 16px">
<n-input <n-input
v-model:value="query.username" v-model:value="query.username"
placeholder="筛选用户名" placeholder="筛选用户名"
@@ -263,7 +262,8 @@ onMounted(loadData)
clearable clearable
/> />
<n-select v-model:value="query.checked" :options="checkedOptions" style="width: 120px" /> <n-select v-model:value="query.checked" :options="checkedOptions" style="width: 120px" />
</n-flex> </template>
</PageHeader>
<n-data-table <n-data-table
:columns="columns" :columns="columns"
+15 -19
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { NSwitch, NTag } from "naive-ui" import { NSwitch, NTag } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue" import ContestTitle from "shared/components/ContestTitle.vue"
import ContestAccessTag from "shared/components/ContestAccessTag.vue" import ContestAccessTag from "shared/components/ContestAccessTag.vue"
@@ -79,6 +80,7 @@ const columns: DataTableColumn<AdminContest>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
fixed: "right",
width: 300, width: 300,
render: (row) => h(Actions, { contest: row }), render: (row) => h(Actions, { contest: row }),
}, },
@@ -99,27 +101,21 @@ watchDebounced(() => query.keyword, listContests, {
</script> </script>
<template> <template>
<n-flex justify="space-between" class="titleWrapper"> <PageHeader title="比赛列表">
<n-flex align="center"> <template #actions>
<h2 class="title">比赛列表</h2>
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })"> <n-button type="primary" @click="$router.push({ name: 'admin contest create' })">
新建 新建比赛
</n-button> </n-button>
</n-flex> </template>
<div> <template #filters>
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" /> <n-input
</div> v-model:value="query.keyword"
</n-flex> placeholder="输入标题关键字"
clearable
style="width: 220px"
/>
</template>
</PageHeader>
<n-data-table :columns="columns" :data="contests" /> <n-data-table :columns="columns" :data="contests" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+9 -10
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { TUTORIAL_READ_SECONDS } from "@oj2/contract" import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import { NProgress, NTag, NText } from "naive-ui" import { NProgress, NTag, NText } from "naive-ui"
import { getLearnStudents, getLearnTutorials, getLearnExercises } from "admin/api" import { getLearnStudents, getLearnTutorials, getLearnExercises } from "admin/api"
@@ -357,9 +358,12 @@ onMounted(load)
</script> </script>
<template> <template>
<h2 style="margin-top: 0">自学情况</h2> <!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
<PageHeader
<n-flex align="center" style="margin-bottom: 16px"> title="自学情况"
:description="`「已读」按累计停留满 ${TUTORIAL_READ_SECONDS / 60} 分钟算,不足的只计时长`"
>
<template #filters>
<n-radio-group v-model:value="type" size="small"> <n-radio-group v-model:value="type" size="small">
<n-radio-button <n-radio-button
v-for="item in typeOptions" v-for="item in typeOptions"
@@ -377,13 +381,8 @@ onMounted(load)
@clear="load" @clear="load"
/> />
<n-button type="primary" secondary @click="load">查询</n-button> <n-button type="primary" secondary @click="load">查询</n-button>
<n-text depth="3"> {{ studentCount }} 名学生,{{ startedCount }} 人已经开始学 </n-text> </template>
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 --> </PageHeader>
<n-text depth="3" style="font-size: 12px">
「已读」按累计停留满
{{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
</n-text>
</n-flex>
<n-grid cols="2 s:3 m:5" :x-gap="12" :y-gap="12" responsive="screen" style="margin-bottom: 16px"> <n-grid cols="2 s:3 m:5" :x-gap="12" :y-gap="12" responsive="screen" style="margin-bottom: 16px">
<n-gi> <n-gi>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { getStuckProblems } from "admin/api" import { getStuckProblems } from "admin/api"
import type { StuckProblem } from "utils/types" import type { StuckProblem } from "utils/types"
@@ -37,7 +38,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<h2 style="margin-top: 0">学生卡点分析(只分析前40道题目)</h2> <PageHeader title="卡点分析" description="只分析前 40 道题目" />
<n-data-table <n-data-table
:loading="loading" :loading="loading"
:columns="columns" :columns="columns"
+8 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import type { AcTrend } from "utils/types" import type { AcTrend } from "utils/types"
import { Line } from "vue-chartjs" import { Line } from "vue-chartjs"
import { import {
@@ -127,8 +128,11 @@ onMounted(fetchData)
</script> </script>
<template> <template>
<h2 style="margin-top: 0">年度趋势</h2> <PageHeader title="年度趋势">
<n-space align="center" style="margin-bottom: 16px"> <template #extra>
<n-tag type="info" size="small" round>共 {{ data.length }} 题</n-tag>
</template>
<template #filters>
<span>年份范围</span> <span>年份范围</span>
<n-select <n-select
v-model:value="sinceYear" v-model:value="sinceYear"
@@ -150,8 +154,8 @@ onMounted(fetchData)
style="width: 90px" style="width: 90px"
@update:value="fetchData" @update:value="fetchData"
/> />
<n-tag type="info" size="small">共 {{ data.length }} 题</n-tag> </template>
</n-space> </PageHeader>
<n-spin :show="loading"> <n-spin :show="loading">
<div v-if="!loading && data.length === 0" style="text-align: center; padding: 40px"> <div v-if="!loading && data.length === 0" style="text-align: center; padding: 40px">
暂无数据 暂无数据
+12 -7
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorCode, errorMessage } from "utils/api" import { errorCode, errorMessage } from "utils/api"
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract" import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
@@ -529,10 +530,18 @@ watch(
</script> </script>
<template> <template>
<n-flex> <PageHeader
<h2 class="title">{{ title }}</h2> :title="title ?? '题目'"
:back="
props.contestID
? { name: 'admin contest problem list', params: { contestID: props.contestID } }
: { name: 'admin problem list' }
"
>
<template #actions>
<n-button v-if="showClear" @click="clear">清空缓存</n-button> <n-button v-if="showClear" @click="clear">清空缓存</n-button>
</n-flex> </template>
</PageHeader>
<n-form inline label-placement="left"> <n-form inline label-placement="left">
<n-form-item label="显示编号"> <n-form-item label="显示编号">
<n-input class="w-100" v-model:value="problem._id" /> <n-input class="w-100" v-model:value="problem._id" />
@@ -832,10 +841,6 @@ watch(
</template> </template>
<style scoped> <style scoped>
.title {
margin-top: 0;
}
.box { .box {
margin-bottom: 20px; margin-bottom: 20px;
} }
+25 -46
View File
@@ -13,6 +13,7 @@ import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue" import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui" import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue" import BatchTagModal from "./components/BatchTagModal.vue"
import PageHeader from "admin/components/PageHeader.vue"
interface Props { interface Props {
contestID?: string contestID?: string
@@ -166,6 +167,7 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
fixed: "right",
width: 320, width: 320,
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
@@ -232,54 +234,41 @@ watch(() => [query.page, query.limit, query.author], listProblems)
</script> </script>
<template> <template>
<n-flex class="titleWrapper" justify="space-between"> <PageHeader
<n-flex align="center"> :title="title ?? '题目列表'"
<h2 class="title">{{ title }}</h2> :back="isContestProblemList ? { name: 'admin contest list' } : undefined"
<n-button
v-if="!isContestProblemList"
type="primary"
@click="$router.push({ name: 'admin problem create' })"
> >
新建 <template #actions>
</n-button> <template v-if="!isContestProblemList">
<n-button <template v-if="selectedProblemIds.length">
v-if="!isContestProblemList" <n-button @click="openBatchTag('remove')">移除标签</n-button>
@click="$router.push({ name: 'admin stuck problems' })" <n-button secondary type="primary" @click="openBatchTag('add')">
>
卡点分析
</n-button>
<n-button v-if="!isContestProblemList" @click="$router.push({ name: 'admin top ac trend' })">
年度趋势
</n-button>
<n-button v-if="!isContestProblemList" @click="$router.push({ name: 'admin tag list' })">
标签管理
</n-button>
</n-flex>
<n-flex>
<template v-if="!isContestProblemList && selectedProblemIds.length">
<n-button type="primary" @click="openBatchTag('add')">
添加标签({{ selectedProblemIds.length }}) 添加标签({{ selectedProblemIds.length }})
</n-button> </n-button>
<n-button @click="openBatchTag('remove')">移除标签</n-button>
</template> </template>
<n-button v-if="isContestProblemList" @click="createContestProblem"> 新建比赛题目 </n-button> <n-button type="primary" @click="$router.push({ name: 'admin problem create' })">
<n-button v-if="isContestProblemList" type="primary" @click="selectProblems"> 新建题目
从题目中选择
</n-button> </n-button>
<n-flex align="center" v-if="!props.contestID"> </template>
<span>出题人</span> <template v-else>
<AuthorSelect v-model:value="query.author" all /> <n-button @click="createContestProblem">新建比赛题目</n-button>
</n-flex> <n-button type="primary" @click="selectProblems">从题库中选择</n-button>
<div> </template>
</template>
<template #filters>
<n-input <n-input
v-model:value="query.keyword" v-model:value="query.keyword"
placeholder="输入标题关键字" placeholder="输入标题关键字"
clearable clearable
style="width: 220px"
@clear="clearQuery" @clear="clearQuery"
/> />
</div> <n-flex v-if="!props.contestID" align="center" :size="8">
</n-flex> <n-text depth="3">出题人</n-text>
<AuthorSelect v-model:value="query.author" all />
</n-flex> </n-flex>
</template>
</PageHeader>
<n-data-table <n-data-table
striped striped
:columns="columns" :columns="columns"
@@ -301,13 +290,3 @@ watch(() => [query.page, query.limit, query.author], listProblems)
@done="onBatchTagDone" @done="onBatchTagDone"
/> />
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+6 -17
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex, NInput } from "naive-ui" import { NButton, NFlex, NInput } from "naive-ui"
import type { AdminTag } from "utils/types" import type { AdminTag } from "utils/types"
import { deleteTag, getTagAdminList, renameTag } from "../api" import { deleteTag, getTagAdminList, renameTag } from "../api"
@@ -144,13 +145,11 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</script> </script>
<template> <template>
<n-flex class="titleWrapper" justify="space-between"> <PageHeader title="标签管理">
<n-flex align="center"> <template #filters>
<h2 class="title">标签管理</h2> <n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
<n-button @click="$router.push({ name: 'admin problem list' })"> 返回题目列表 </n-button> </template>
</n-flex> </PageHeader>
<n-input v-model:value="keyword" style="width: 200px" placeholder="搜索标签" clearable />
</n-flex>
<n-data-table striped :columns="columns" :data="tags" /> <n-data-table striped :columns="columns" :data="tags" />
<TagProblemsModal <TagProblemsModal
v-model:show="showTagProblems" v-model:show="showTagProblems"
@@ -159,13 +158,3 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
@changed="listTags" @changed="listTags"
/> />
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+6 -14
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui" import { NTabPane, NTabs, NButton } from "naive-ui"
import type { import type {
AdminProblemSet, AdminProblemSet,
AdminProblemSetProblem, AdminProblemSetProblem,
@@ -200,8 +201,8 @@ onMounted(() => {
<template> <template>
<div v-if="problemSet"> <div v-if="problemSet">
<n-flex class="titleWrapper" justify="space-between" align="center"> <PageHeader :title="problemSet.title" :back="{ name: 'admin problemset list' }">
<h2 class="title">{{ problemSet.title }}</h2> <template #actions>
<n-button <n-button
type="primary" type="primary"
@click=" @click="
@@ -213,7 +214,8 @@ onMounted(() => {
> >
编辑题单 编辑题单
</n-button> </n-button>
</n-flex> </template>
</PageHeader>
<ProblemSetInfo :problem-set="problemSet" /> <ProblemSetInfo :problem-set="problemSet" />
@@ -257,13 +259,3 @@ onMounted(() => {
/> />
</div> </div>
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+9 -7
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import type { CreateProblemSetData, EditProblemSetData } from "utils/types" import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { fromPickerValue, toPickerValue } from "utils/functions" import { fromPickerValue, toPickerValue } from "utils/functions"
@@ -101,7 +102,14 @@ onMounted(() => {
<template> <template>
<div> <div>
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2> <PageHeader
:title="isEdit ? '编辑题单' : '新建题单'"
:back="
isEdit
? { name: 'admin problemset detail', params: { problemSetId } }
: { name: 'admin problemset list' }
"
/>
<n-form :model="formData" label-placement="top"> <n-form :model="formData" label-placement="top">
<n-flex> <n-flex>
@@ -158,9 +166,3 @@ onMounted(() => {
</n-form> </n-form>
</div> </div>
</template> </template>
<style scoped>
.title {
margin: 0 0 16px 0;
}
</style>
+23 -38
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
@@ -96,6 +97,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
fixed: "right",
width: 300, width: 300,
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
@@ -142,53 +144,36 @@ watch(() => [query.page, query.limit, query.difficulty, query.status], listProbl
</script> </script>
<template> <template>
<n-flex class="titleWrapper" justify="space-between"> <PageHeader title="题单列表">
<n-flex align="center"> <template #actions>
<h2 class="title">题单管理</h2>
<n-button type="primary" @click="$router.push({ name: 'admin problemset create' })"> <n-button type="primary" @click="$router.push({ name: 'admin problemset create' })">
新建题单 新建题单
</n-button> </n-button>
</n-flex> </template>
<n-flex align="center"> <template #filters>
<n-flex align="center">
<span>难度:</span>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-flex>
<n-flex align="center">
<span>状态:</span>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-flex>
<n-input <n-input
v-model:value="query.keyword" v-model:value="query.keyword"
placeholder="输入标题关键字" placeholder="输入标题关键字"
clearable clearable
@clear="clearQuery" @clear="clearQuery"
style="width: 200px" style="width: 220px"
/> />
</n-flex> <n-select
</n-flex> v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="全部难度"
style="width: 120px"
clearable
/>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="全部状态"
style="width: 120px"
clearable
/>
</template>
</PageHeader>
<n-data-table striped :columns="columns" :data="problemSets" /> <n-data-table striped :columns="columns" :data="problemSets" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+8 -15
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants" import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants"
@@ -250,12 +251,10 @@ onMounted(() => {
</script> </script>
<template> <template>
<n-card class="box"> <PageHeader title="设置" />
<template #header> <n-card class="box" title="网站">
<n-flex align="center"> <template #header-extra>
网站设置
<n-button type="primary" size="small" @click="saveWebsiteConfig">保存</n-button> <n-button type="primary" size="small" @click="saveWebsiteConfig">保存</n-button>
</n-flex>
</template> </template>
<n-form inline label-placement="left"> <n-form inline label-placement="left">
<n-form-item label="网站 URL"> <n-form-item label="网站 URL">
@@ -295,10 +294,8 @@ onMounted(() => {
</n-flex> </n-flex>
</n-flex> </n-flex>
</n-card> </n-card>
<n-card class="box"> <n-card class="box" title="判题服务器">
<template #header> <template #header-extra>
<n-flex align="center">
判题服务器
<n-button <n-button
v-if="abnormalServers.length" v-if="abnormalServers.length"
size="small" size="small"
@@ -307,19 +304,15 @@ onMounted(() => {
> >
删除无效服务器 删除无效服务器
</n-button> </n-button>
</n-flex>
</template> </template>
<div class="box"> <div class="box">
接口凭证 <n-tag size="small">{{ token }}</n-tag> 接口凭证 <n-tag size="small">{{ token }}</n-tag>
</div> </div>
<n-data-table :single-line="false" striped :columns="serverColumns" :data="servers" /> <n-data-table :single-line="false" striped :columns="serverColumns" :data="servers" />
</n-card> </n-card>
<n-card class="box" v-if="testcases.length"> <n-card class="box" v-if="testcases.length" title="无效的测试用例">
<template #header> <template #header-extra>
<n-flex align="center">
无效的测试用例
<n-button size="small" type="warning" @click="() => deleteTestcase()">全部删除</n-button> <n-button size="small" type="warning" @click="() => deleteTestcase()">全部删除</n-button>
</n-flex>
</template> </template>
<n-data-table striped class="table" :columns="testcaseColumns" :data="testcases" /> <n-data-table striped class="table" :columns="testcaseColumns" :data="testcases" />
</n-card> </n-card>
+45 -47
View File
@@ -2,6 +2,7 @@
import { h, onMounted, reactive, ref, watch } from "vue" import { h, onMounted, reactive, ref, watch } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import { NButton } from "naive-ui" import { NButton } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue"
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 { getACRate } from "utils/functions" import { getACRate } from "utils/functions"
@@ -151,50 +152,56 @@ watch(showModal, (v) => {
</script> </script>
<template> <template>
<n-flex align="center"> <PageHeader :title="`你好,${userStore.user?.username ?? '管理员'}`">
<n-avatar round :size="60" :src="userStore.profile?.avatar" /> <template #actions>
<h1 class="name">亲爱的管理员:{{ userStore.user?.username }}</h1> <n-button @click="router.push('/admin/contest/create')">新建比赛</n-button>
</n-flex> <n-button type="primary" @click="router.push('/admin/problem/create')">新建题目</n-button>
<n-flex> </template>
<h2> </PageHeader>
<n-gradient-text type="info"> 总用户数:{{ userCount }} </n-gradient-text>
</h2> <n-grid cols="2 m:4" :x-gap="12" :y-gap="12" responsive="screen" class="stats">
<h2> <n-gi>
<n-gradient-text type="error"> 今日提交:{{ submissionCount }} </n-gradient-text> <n-card size="small"><n-statistic label="总用户数" :value="userCount" /></n-card>
</h2> </n-gi>
<h2> <n-gi>
<n-gradient-text type="warning"> 近期比赛:{{ contestCount }} </n-gradient-text> <n-card size="small"><n-statistic label="今日提交" :value="submissionCount" /></n-card>
</h2> </n-gi>
<h2> <n-gi>
<n-card size="small"><n-statistic label="近期比赛" :value="contestCount" /></n-card>
</n-gi>
<n-gi>
<!-- 判题机数量后端一直在下发,这里以前没显示 —— 判题机全掉线的时候, <!-- 判题机数量后端一直在下发,这里以前没显示 —— 判题机全掉线的时候,
这一栏是 0,比学生喊「交了没反应」早得多 --> 这一栏是 0,比学生喊「交了没反应」早得多 -->
<n-gradient-text :type="judgeServerCount > 0 ? 'success' : 'error'"> <n-card size="small">
在线判题机:{{ judgeServerCount }} <n-statistic label="在线判题机">
</n-gradient-text> <n-text :type="judgeServerCount > 0 ? 'success' : 'error'">
</h2> {{ judgeServerCount }}
</n-flex> </n-text>
<n-flex align="center" class="actions"> </n-statistic>
<span>我猜你要:</span> </n-card>
<n-button @click="router.push('/admin/problem/create')">新题目</n-button> </n-gi>
<n-button @click="router.push('/admin/contest/create')">新比赛</n-button> </n-grid>
<div>
<n-card size="small" title="班级排名">
<template #header-extra>
<n-flex align="center" :size="8">
<n-input <n-input
style="width: 200px" style="width: 160px"
clearable clearable
v-model:value="query.classroom" v-model:value="query.classroom"
placeholder="班级前缀" placeholder="班级前缀"
@keyup.enter="listRanks"
/> />
</div> <n-button secondary type="primary" @click="listRanks">查询</n-button>
<n-button @click="listRanks">用户排名</n-button> <n-button v-if="query.classroom" @click="getRandomModal">随机抽签</n-button>
<n-button @click="getRandomModal" v-if="query.classroom">随机抽签</n-button>
<Pagination
class="pagination"
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</n-flex> </n-flex>
<n-data-table v-if="data.length" striped :data="data" :columns="columns" /> </template>
<template v-if="data.length">
<n-data-table striped :data="data" :columns="columns" />
<Pagination :total="total" v-model:page="query.page" v-model:limit="query.limit" />
</template>
<n-empty v-else description="输入班级前缀后查询" style="padding: 24px 0" />
</n-card>
<n-modal preset="card" title="猜猜看幸运儿是谁?" v-model:show="showModal" style="width: 400px"> <n-modal preset="card" title="猜猜看幸运儿是谁?" v-model:show="showModal" style="width: 400px">
<n-flex vertical justify="center" align="center"> <n-flex vertical justify="center" align="center">
<n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1> <n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1>
@@ -206,17 +213,8 @@ watch(showModal, (v) => {
</template> </template>
<style scoped> <style scoped>
.name { .stats {
font-size: 32px; margin-bottom: 16px;
margin: 0;
}
.actions {
margin-bottom: 20px;
}
.pagination {
margin: 0;
} }
.lucky { .lucky {
+5 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import CodeEditor from "shared/components/CodeEditor.vue" import CodeEditor from "shared/components/CodeEditor.vue"
import MarkdownEditor from "shared/components/MarkdownEditor.vue" import MarkdownEditor from "shared/components/MarkdownEditor.vue"
@@ -72,9 +73,10 @@ async function submit() {
onMounted(init) onMounted(init)
</script> </script>
<template> <template>
<h2 class="title"> <PageHeader
{{ route.name === "admin tutorial create" ? "新建教程" : "编辑教程" }} :title="route.name === 'admin tutorial create' ? '新建教程' : '编辑教程'"
</h2> :back="{ name: 'admin tutorial list' }"
/>
<n-form inline> <n-form inline>
<n-form-item label="标题"> <n-form-item label="标题">
<n-input class="contestTitle" v-model:value="tutorial.title" /> <n-input class="contestTitle" v-model:value="tutorial.title" />
@@ -111,10 +113,6 @@ onMounted(init)
</n-tabs> </n-tabs>
</template> </template>
<style scoped> <style scoped>
.title {
margin-top: 0;
}
.select { .select {
width: 100px; width: 100px;
} }
+6 -14
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { NSwitch } from "naive-ui" import { NSwitch } from "naive-ui"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
@@ -77,12 +78,13 @@ onMounted(listTutorials)
</script> </script>
<template> <template>
<n-flex align="center" class="titleWrapper"> <PageHeader title="教程列表">
<h2 class="title">教程列表</h2> <template #actions>
<n-button type="primary" @click="$router.push({ name: 'admin tutorial create' })"> <n-button type="primary" @click="$router.push({ name: 'admin tutorial create' })">
新建 新建教程
</n-button> </n-button>
</n-flex> </template>
</PageHeader>
<n-tabs v-model:value="activeTab" type="line" animated> <n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="python" tab="Python"> <n-tab-pane name="python" tab="Python">
<n-data-table striped :columns="columns" :data="tutorials.python" /> <n-data-table striped :columns="columns" :data="tutorials.python" />
@@ -92,13 +94,3 @@ onMounted(listTutorials)
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { import {
CLASS_NAME_MAX_DIGITS, CLASS_NAME_MAX_DIGITS,
@@ -81,6 +82,11 @@ async function submit() {
</script> </script>
<template> <template>
<PageHeader
title="批量导入用户"
description="每行一个名字,用户名为 ks + 班级号 + 名字,密码随机生成;导入后会下载账号清单"
:back="{ name: 'admin user list' }"
/>
<n-space> <n-space>
<n-flex vertical> <n-flex vertical>
<n-flex align="center"> <n-flex align="center">
+22 -34
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui" import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
@@ -105,6 +106,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
key: "actions", key: "actions",
title: "选项", title: "选项",
width: 280, width: 280,
fixed: "right",
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
user: row, user: row,
@@ -252,25 +254,24 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
</script> </script>
<template> <template>
<n-flex class="titleWrapper" justify="space-between"> <PageHeader title="用户列表">
<n-flex> <template #actions>
<h2 class="title">用户列表</h2>
<n-button type="primary" @click="createNewUser">新建</n-button>
<n-button @click="$router.push({ name: 'admin user generate' })"> 导入 </n-button>
</n-flex>
<n-flex>
<n-popconfirm v-if="userIDs.length" @positive-click="onDeleteUsers(userIDs)"> <n-popconfirm v-if="userIDs.length" @positive-click="onDeleteUsers(userIDs)">
<template #trigger> <template #trigger>
<n-button type="warning">删除</n-button> <n-button type="error" secondary>删除选中({{ userIDs.length }})</n-button>
</template> </template>
确定删除选中的用户吗?删除后无法恢复! 确定删除选中的用户吗?删除后无法恢复!
</n-popconfirm> </n-popconfirm>
<n-flex align="center"> <n-button @click="$router.push({ name: 'admin user generate' })">批量导入</n-button>
<n-select <n-button type="primary" @click="createNewUser">新建用户</n-button>
v-model:value="query.orderBy" </template>
:options="sortOptions" <template #filters>
placeholder="排序方式" <n-input
style="width: 120px" style="width: 220px"
v-model:value="query.keyword"
placeholder="用户名 / 真名 / 邮箱"
clearable
@clear="clearQuery"
/> />
<n-select <n-select
v-model:value="query.type" v-model:value="query.type"
@@ -278,17 +279,14 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
placeholder="选择用户类型" placeholder="选择用户类型"
style="width: 120px" style="width: 120px"
/> />
<div> <n-select
<n-input v-model:value="query.orderBy"
style="width: 200px" :options="sortOptions"
v-model:value="query.keyword" placeholder="排序方式"
clearable style="width: 120px"
@clear="clearQuery"
/> />
</div> </template>
</n-flex> </PageHeader>
</n-flex>
</n-flex>
<n-data-table <n-data-table
:data="users" :data="users"
:columns="columns" :columns="columns"
@@ -353,13 +351,3 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+283 -105
View File
@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from "vue" import { h } from "vue"
import { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router" import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
import { useUserStore } from "../store/user" import { useUserStore } from "../store/user"
@@ -9,122 +12,205 @@ import type { MenuOption } from "naive-ui"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { isMobile } = useBreakpoints()
const { isDark, toggleDark } = useDarkTransition()
// 根据用户权限动态生成菜单选项 /**
const options = computed<MenuOption[]>(() => { * 菜单的唯一一张表。`role` 必须和 routes.ts 里对应路由的 meta 对齐:
const baseOptions: MenuOption[] = [ * 菜单里露出一个守卫不放行的入口,点下去就是被静默踢回首页。
* `path` 同时用来判定高亮:取当前路径能匹配上的**最长**那一条,所以
* /admin/problem/stuck 亮「卡点分析」而不是「题目」。
*/
type Role = "problem" | "teacher" | "super"
interface NavItem {
label: string
icon: string
path: string
/** 点击跳到哪;不写就是 path 本身(path 只是前缀时必须写,前缀不一定有路由) */
to?: string
role: Role
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV: NavGroup[] = [
{ {
label: () => h(RouterLink, { to: "/" }, { default: () => "前台" }), label: "概览",
key: "return to OJ", items: [{ label: "首页", icon: "ph:house", path: "/admin", role: "super" }],
},
{
label: "教学内容",
items: [
{
label: "题目",
icon: "ph:code",
path: "/admin/problem",
to: "/admin/problem/list",
role: "problem",
},
{ label: "标签", icon: "ph:tag", path: "/admin/problem/tags", role: "problem" },
{
label: "比赛",
icon: "ph:flag-checkered",
path: "/admin/contest",
to: "/admin/contest/list",
role: "teacher",
},
{
label: "题单",
icon: "ph:list-checks",
path: "/admin/problemset",
to: "/admin/problemset/list",
role: "teacher",
},
{
label: "教程",
icon: "ph:book-open",
path: "/admin/tutorial",
to: "/admin/tutorial/list",
role: "super",
},
],
},
{
label: "学情分析",
items: [
{
label: "卡点分析",
icon: "ph:warning-diamond",
path: "/admin/problem/stuck",
role: "teacher",
},
{
label: "年度趋势",
icon: "ph:chart-line-up",
path: "/admin/problem/top_ac_trend",
role: "teacher",
},
{ label: "自学情况", icon: "ph:student", path: "/admin/learn", role: "teacher" },
{
label: "AI 报告",
icon: "ph:sparkle",
path: "/admin/ai",
to: "/admin/ai/reports",
role: "teacher",
},
],
},
{
label: "站点管理",
items: [
{
label: "用户",
icon: "ph:users",
path: "/admin/user",
to: "/admin/user/list",
role: "super",
},
{
label: "公告",
icon: "ph:megaphone",
path: "/admin/announcement",
to: "/admin/announcement/list",
role: "super",
},
{
label: "成就",
icon: "ph:medal",
path: "/admin/achievement",
to: "/admin/achievement/list",
role: "super",
},
{ label: "设置", icon: "ph:gear-six", path: "/admin/config", role: "super" },
],
}, },
] ]
// Student Admin: only problems function allowed(role: Role) {
if (userStore.isStudentAdmin) { if (role === "super") return userStore.isSuperAdmin
baseOptions.push({ if (role === "teacher") return userStore.isTeacherOrAbove
label: () => h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }), return userStore.hasProblemPermission
key: "admin problem list",
})
} }
// Teacher Admin: problems + contests + problemsets const groups = computed(() =>
if (userStore.isTeacherAdmin) { NAV.map((g) => ({ ...g, items: g.items.filter((i) => allowed(i.role)) })).filter(
baseOptions.push( (g) => g.items.length,
{ ),
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",
},
{
label: () => h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
key: "admin ai reports",
},
{
label: () => h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
key: "admin learn analytics",
},
) )
function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 18 })
} }
// Super Admin: everything function toOption(i: NavItem): MenuOption {
if (userStore.isSuperAdmin) { return {
baseOptions.push( key: i.path,
{ icon: renderIcon(i.icon),
label: () => h(RouterLink, { to: "/admin" }, { default: () => "管理" }), label: () => h(RouterLink, { to: i.to ?? i.path }, { default: () => i.label }),
key: "admin home", }
}, }
{
label: () => h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }), // 收起时 64px 放不下分组标题(会折成两行),换成分隔线
key: "admin config", const options = computed<MenuOption[]>(() =>
}, siderCollapsed.value
{ ? groups.value.flatMap((g, idx) => [
label: () => h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }), ...(idx ? [{ type: "divider", key: `divider-${g.label}` }] : []),
key: "admin problem list", ...g.items.map(toOption),
}, ])
{ : groups.value.map((g) => ({
label: () => h(RouterLink, { to: "/admin/contest/list" }, { default: () => "比赛" }), type: "group",
key: "admin contest list", key: g.label,
}, label: g.label,
{ children: g.items.map(toOption),
label: () => h(RouterLink, { to: "/admin/problemset/list" }, { default: () => "题单" }), })),
key: "admin problemset list",
},
{
label: () => h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user 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",
},
{
label: () => h(RouterLink, { to: "/admin/achievement/list" }, { default: () => "成就" }),
key: "admin achievement list",
},
{
label: () => h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
key: "admin ai reports",
},
{
label: () => h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
key: "admin learn analytics",
},
) )
function matches(path: string, prefix: string) {
// 首页只能精确匹配,否则它是所有后台路径的前缀
if (prefix === "/admin") return path === "/admin" || path === "/admin/"
return path === prefix || path.startsWith(prefix + "/")
} }
return baseOptions const current = computed(() => {
let best: { group: NavGroup; item: NavItem } | null = null
for (const group of groups.value) {
for (const item of group.items) {
if (matches(route.path, item.path) && item.path.length > (best?.item.path.length ?? 0)) {
best = { group, item }
}
}
}
return best
}) })
// 根据路径计算当前激活的菜单项 const collapsed = ref<boolean>(storage.get(STORAGE_KEY.ADMIN_SIDER_COLLAPSED) ?? false)
const active = computed(() => { watch(collapsed, (v) => storage.set(STORAGE_KEY.ADMIN_SIDER_COLLAPSED, v))
const path = route.path // 窄屏一律收起,不写回偏好 —— 手机上看一眼不该改掉桌面端的习惯
if (path === "/") return "return to OJ" const siderCollapsed = computed({
if (path === "/admin") return "admin home" get: () => isMobile.value || collapsed.value,
if (path.startsWith("/admin/config")) return "admin config" set: (v) => {
if (path.startsWith("/admin/problemset")) return "admin problemset list" if (!isMobile.value) collapsed.value = v
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems" },
if (path.startsWith("/admin/problem/tags")) return "admin problem list"
if (path.startsWith("/admin/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list"
if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
if (path.startsWith("/admin/learn")) return "admin learn analytics"
return route.name as string
}) })
const userOptions: DropdownOption[] = [
{ label: "回到前台", key: "home", icon: renderIcon("ph:arrow-square-out") },
{ type: "divider", key: "d" },
{ label: "退出登录", key: "logout", icon: renderIcon("ph:sign-out") },
]
async function onUserSelect(key: string) {
if (key === "home") {
router.push("/")
} else if (key === "logout") {
await userStore.signOut()
// 与前台 Header 一致:整页跳转把各个 store 里的数据清干净
window.location.replace("/")
}
}
onMounted(async () => { onMounted(async () => {
if (!storage.get(STORAGE_KEY.AUTHED)) { if (!storage.get(STORAGE_KEY.AUTHED)) {
router.replace("/") router.replace("/")
@@ -139,17 +225,109 @@ onMounted(async () => {
<template> <template>
<n-layout has-sider position="absolute"> <n-layout has-sider position="absolute">
<!-- 侧边栏 --> <n-layout-sider
<n-layout-sider bordered :width="100" :native-scrollbar="false"> bordered
<!-- 菜单 --> collapse-mode="width"
<n-menu :options="options" :value="active" /> :collapsed-width="64"
:width="200"
:native-scrollbar="false"
:show-trigger="isMobile ? false : 'bar'"
v-model:collapsed="siderCollapsed"
>
<RouterLink to="/" class="brand" :class="{ collapsed: siderCollapsed }" title="回到前台">
<Icon icon="streamline-emojis:dog" :height="28" />
<span v-if="!siderCollapsed" class="brandText">后台管理</span>
</RouterLink>
<n-menu
:options="options"
:value="current?.item.path ?? null"
:collapsed="siderCollapsed"
:collapsed-width="64"
:collapsed-icon-size="20"
:indent="20"
:theme-overrides="{ itemHeight: '36px' }"
/>
</n-layout-sider> </n-layout-sider>
<!-- 主内容区域 -->
<n-layout> <n-layout>
<n-layout-content content-style="padding: 20px"> <n-layout-header bordered class="topbar">
<n-breadcrumb v-if="!isMobile">
<n-breadcrumb-item>后台</n-breadcrumb-item>
<template v-if="current">
<n-breadcrumb-item>{{ current.group.label }}</n-breadcrumb-item>
<n-breadcrumb-item>
<RouterLink :to="current.item.to ?? current.item.path">{{
current.item.label
}}</RouterLink>
</n-breadcrumb-item>
</template>
</n-breadcrumb>
<div v-if="isMobile" />
<n-flex align="center" :size="8" :wrap="false">
<n-button v-if="!isMobile" quaternary size="small" @click="router.push('/')">
<template #icon><Icon icon="ph:arrow-square-out" /></template>
回到前台
</n-button>
<n-button quaternary circle size="small" @click="toggleDark" title="切换明暗">
<template #icon>
<Icon :icon="isDark ? 'ph:sun' : 'ph:moon'" />
</template>
</n-button>
<n-dropdown
v-if="userStore.user"
:options="userOptions"
trigger="click"
@select="onUserSelect"
>
<n-button quaternary size="small">
<template #icon><Icon icon="ph:user-circle" /></template>
<template v-if="!isMobile">{{ userStore.user.username }}</template>
</n-button>
</n-dropdown>
</n-flex>
</n-layout-header>
<n-layout-content
position="absolute"
style="top: 49px"
:native-scrollbar="false"
content-style="padding: 20px 24px"
>
<router-view></router-view> <router-view></router-view>
</n-layout-content> </n-layout-content>
</n-layout> </n-layout>
</n-layout> </n-layout>
</template> </template>
<style scoped>
.brand {
display: flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 20px;
color: inherit;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
}
.brand.collapsed {
justify-content: center;
padding: 0;
}
.brandText {
font-size: 16px;
font-weight: 600;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 49px;
padding: 0 16px 0 24px;
box-sizing: border-box;
}
</style>
+1
View File
@@ -152,6 +152,7 @@ export const STORAGE_KEY = {
LEARN_CURRENT_STEP: "learnStep", LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem", ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags", ADMIN_PROBLEM_TAGS: "adminProblemTags",
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode", DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass", LOGIN_CLASS: "loginClass",
} }