- 侧栏按 概览 / 教学内容 / 学情分析 / 站点管理 分组,去掉超管菜单里的重复项; 菜单项的角色与路由守卫一一对应,没有题目权限的教师不再看到点了就被踢回首页的「题目」 - 顶栏:面包屑、回到前台、明暗切换、用户菜单(退出登录);窄屏侧栏自动收起 - 新增 PageHeader:标题 + 右侧主操作 + 下方筛选栏,编辑页带返回按钮 - 卡点分析 / 年度趋势 / 标签管理从题目列表的按钮挪进侧栏 - 首页改成统计卡片 + 班级排名卡片;设置页按钮移到卡片右上角 - 题目、比赛、题单、用户列表的「选项」列固定在右侧,不再被挤出屏幕 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d25b5dbe6d
commit
d3823430f9
25 files changed
+764
-587
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user