- 侧栏按 概览 / 教学内容 / 学情分析 / 站点管理 分组,去掉超管菜单里的重复项; 菜单项的角色与路由守卫一一对应,没有题目权限的教师不再看到点了就被踢回首页的「题目」 - 顶栏:面包屑、回到前台、明暗切换、用户菜单(退出登录);窄屏侧栏自动收起 - 新增 PageHeader:标题 + 右侧主操作 + 下方筛选栏,编辑页带返回按钮 - 卡点分析 / 年度趋势 / 标签管理从题目列表的按钮挪进侧栏 - 首页改成统计卡片 + 班级排名卡片;设置页按钮移到卡片右上角 - 题目、比赛、题单、用户列表的「选项」列固定在右侧,不再被挤出屏幕 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d25b5dbe6d
commit
d3823430f9
25 files changed
+650
-473
No files matched your search
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
暂无数据
|
暂无数据
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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",
|
}
|
||||||
})
|
|
||||||
|
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
|
// 收起时 64px 放不下分组标题(会折成两行),换成分隔线
|
||||||
if (userStore.isTeacherAdmin) {
|
const options = computed<MenuOption[]>(() =>
|
||||||
baseOptions.push(
|
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",
|
)
|
||||||
},
|
|
||||||
{
|
function matches(path: string, prefix: string) {
|
||||||
label: () => h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
|
// 首页只能精确匹配,否则它是所有后台路径的前缀
|
||||||
key: "admin ai reports",
|
if (prefix === "/admin") return path === "/admin" || path === "/admin/"
|
||||||
},
|
return path === prefix || path.startsWith(prefix + "/")
|
||||||
{
|
}
|
||||||
label: () => h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
|
|
||||||
key: "admin learn analytics",
|
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 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>
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user