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
+764 -587

No files matched your search

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