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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:54:42 +08:00

240 lines
6.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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"
import type { Rank } from "utils/types"
import { getAdminUserRank, getBaseInfo, randomUser10 } from "../api"
const userCount = ref(0)
const submissionCount = ref(0)
const contestCount = ref(0)
const judgeServerCount = ref(0)
const userStore = useUserStore()
const router = useRouter()
const showModal = ref(false)
const luckyGuy = ref("")
const isRolling = ref(false)
const rollingNames = ref<string[]>([])
const pulseKey = ref(0)
let rollingTimer: ReturnType<typeof setInterval> | null = null
let rollingStopper: ReturnType<typeof setTimeout> | null = null
const data = ref<Rank[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
classroom: "",
})
const columns: DataTableColumn<Rank>[] = [
{
title: "排名",
key: "index",
width: 80,
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: "用户",
key: "username",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{ title: "个性签名", key: "mood" },
{ title: "已解决", key: "acceptedNumber", width: 100 },
{ title: "提交数", key: "submissionNumber", width: 100 },
{
title: "正确率",
key: "rate",
width: 100,
render: (row) => getACRate(row.acceptedNumber, row.submissionNumber),
},
]
onMounted(async () => {
const res = await getBaseInfo()
userCount.value = res.userCount
submissionCount.value = res.todaySubmissionCount
contestCount.value = res.recentContestCount
judgeServerCount.value = res.judgeServerCount
})
async function listRanks() {
const offset = (query.page - 1) * query.limit
const res = await getAdminUserRank(offset, query.limit, query.classroom)
data.value = res.results
total.value = res.total
}
function stopRolling() {
if (rollingTimer) {
clearInterval(rollingTimer)
rollingTimer = null
}
if (rollingStopper) {
clearTimeout(rollingStopper)
rollingStopper = null
}
isRolling.value = false
}
function startRolling(finalName: string) {
stopRolling()
if (!rollingNames.value.length) return
isRolling.value = true
const interval = 80
const duration = 2000
let index = 0
rollingTimer = setInterval(() => {
luckyGuy.value = rollingNames.value[index % rollingNames.value.length]
index += 1
}, interval)
rollingStopper = setTimeout(() => {
stopRolling()
luckyGuy.value = finalName
pulseKey.value += 1
}, duration)
}
async function getRandom() {
const res = await randomUser10(query.classroom)
const names = (res as string[]).map((name) => name.split(query.classroom)[1])
rollingNames.value = names
const finalName = names[names.length - 1]
startRolling(finalName)
}
async function getRandomModal() {
showModal.value = true
stopRolling()
luckyGuy.value = ""
}
watch(() => query.page, listRanks)
watch(
() => query.limit,
() => {
query.page = 1
listRanks()
},
)
watch(
() => query.classroom,
(v) => {
query.page = 1
if (!v) {
data.value = []
total.value = 0
}
},
)
watch(showModal, (v) => {
if (!v) {
stopRolling()
luckyGuy.value = ""
}
})
</script>
<template>
<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-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>
<n-button block :disabled="isRolling" @click="getRandom">
{{ luckyGuy ? "再来一次" : "开始抽签" }}
</n-button>
</n-flex>
</n-modal>
</template>
<style scoped>
.stats {
margin-bottom: 16px;
}
.lucky {
height: 48px;
}
.pulse {
animation: lucky-pulse 0.6s ease-out;
}
@keyframes lucky-pulse {
0% {
transform: scale(0.9);
}
60% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
</style>