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

+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 {