feat(后台首页): 后台首页改成看板——判题机、今天提交、在线、正在上课一排,全站的数一排,最近 30 天每天交了几次一张图;抽签改成选班级
拿掉要手输班级前缀的班级排名表(前台排名页有)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d406a50e4
commit
56071c71bd
1 file changed
+332
-182
+332
-182
@@ -1,224 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { NButton } from "naive-ui"
|
||||
import type { DashboardInfo } from "@oj2/contract"
|
||||
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from "chart.js"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { rgba } from "oj/submission/composables/tone"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { classLabel, groupClassOptions } from "oj/submission/utils"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { getACRate } from "utils/functions"
|
||||
import type { Rank } from "utils/types"
|
||||
import { getAdminUserRank, getBaseInfo, randomUser10 } from "../api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
|
||||
const userCount = ref(0)
|
||||
const submissionCount = ref(0)
|
||||
const contestCount = ref(0)
|
||||
const judgeServerCount = ref(0)
|
||||
const userStore = useUserStore()
|
||||
/**
|
||||
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
|
||||
* 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、
|
||||
* 提交、比赛、题单、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次;
|
||||
* 最下面是常用的四个入口和「抽一个人」。
|
||||
*
|
||||
* 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 ——
|
||||
* 总用户数、近期比赛没人看,排名前台排名页有(还更好看)。「随机抽签」上课点名用,留着,
|
||||
* 班级改成下拉选,默认就是正在上课的那个班
|
||||
*/
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip)
|
||||
|
||||
const theme = useThemeVars()
|
||||
const { chartKey } = useChartTheme()
|
||||
const router = useRouter()
|
||||
const showModal = ref(false)
|
||||
const userStore = useUserStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const info = ref<DashboardInfo | null>(null)
|
||||
onMounted(async () => {
|
||||
info.value = await getBaseInfo()
|
||||
if (!luckyClass.value && info.value.activeClass) luckyClass.value = info.value.activeClass
|
||||
})
|
||||
|
||||
const siteTiles = computed(() => {
|
||||
const site = info.value?.site
|
||||
if (!site) return []
|
||||
const n = (value: number) => value.toLocaleString("zh-CN")
|
||||
return [
|
||||
{ label: "学生", value: n(site.students), note: `老师 ${site.teachers} 人` },
|
||||
{
|
||||
label: "题目",
|
||||
value: n(site.problems),
|
||||
note: `学生看得到 ${n(site.visibleProblems)} 道`,
|
||||
to: "/admin/problem/list",
|
||||
},
|
||||
{ label: "提交", value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
|
||||
{ label: "这周做题的人", value: n(site.weekActive), note: "周一起交过题的" },
|
||||
{ label: "比赛", value: n(site.contests), note: "", to: "/admin/contest/list" },
|
||||
{ label: "题单", value: n(site.problemSets), note: "", to: "/admin/problemset/list" },
|
||||
{ label: "自学", value: n(site.tutorials), note: "课", to: "/admin/tutorial/list" },
|
||||
]
|
||||
})
|
||||
|
||||
/** 最近 30 天每天交了几次。今天那根用主色,其余淡一点 */
|
||||
const dailyData = computed(() => {
|
||||
const daily = info.value?.daily ?? []
|
||||
return {
|
||||
labels: daily.map((d) => d.day.slice(5)),
|
||||
datasets: [
|
||||
{
|
||||
data: daily.map((d) => d.count),
|
||||
backgroundColor: daily.map((_, i) =>
|
||||
i === daily.length - 1 ? theme.value.primaryColor : rgba(theme.value.primaryColor, 0.4),
|
||||
),
|
||||
borderRadius: 3,
|
||||
maxBarThickness: 22,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const dailyOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: { label: (ctx: { parsed: { y: number | null } }) => `${ctx.parsed.y ?? 0} 次` },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkipPadding: 10 } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } },
|
||||
},
|
||||
}
|
||||
|
||||
function openSystemErrors() {
|
||||
router.push({ name: "submissions", query: { result: String(SubmissionStatus.system_error) } })
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ label: "新建题目", to: "/admin/problem/create" },
|
||||
{ label: "新建比赛", to: "/admin/contest/create" },
|
||||
{ label: "批量建号", to: "/admin/user/generate" },
|
||||
{ label: "课堂看板", to: "/classroom" },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------- 抽一个人
|
||||
|
||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||
const luckyClass = ref<string | null>(null)
|
||||
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
|
||||
}
|
||||
if (rollingTimer) clearInterval(rollingTimer)
|
||||
if (rollingStopper) clearTimeout(rollingStopper)
|
||||
rollingTimer = null
|
||||
rollingStopper = null
|
||||
isRolling.value = false
|
||||
}
|
||||
|
||||
function startRolling(finalName: string) {
|
||||
/** 名字滚两秒停下:接口一次给 10 个随机的,最后一个就是抽中的 */
|
||||
async function draw() {
|
||||
if (!luckyClass.value) return
|
||||
const prefix = `ks${luckyClass.value}`
|
||||
const names = (await randomUser10(prefix)).map((name) => name.slice(prefix.length) || name)
|
||||
if (!names.length) {
|
||||
luckyGuy.value = "这个班没有人"
|
||||
return
|
||||
}
|
||||
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]
|
||||
luckyGuy.value = names[index % names.length]!
|
||||
index += 1
|
||||
}, interval)
|
||||
}, 80)
|
||||
rollingStopper = setTimeout(() => {
|
||||
stopRolling()
|
||||
luckyGuy.value = finalName
|
||||
luckyGuy.value = names[names.length - 1]!
|
||||
pulseKey.value += 1
|
||||
}, duration)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
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
|
||||
watch(luckyClass, () => {
|
||||
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 = ""
|
||||
}
|
||||
})
|
||||
onUnmounted(stopRolling)
|
||||
</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>
|
||||
<PageHeader :title="`你好,${userStore.user?.username ?? '管理员'}`" />
|
||||
|
||||
<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>
|
||||
<div v-if="info" class="tiles">
|
||||
<div class="tile">
|
||||
<span class="label">判题机</span>
|
||||
<b :class="info.judgeServerCount ? 'ok' : 'bad'">{{ info.judgeServerCount }} 台在线</b>
|
||||
<span v-if="!info.judgeServerCount" class="bad-note">学生交了会一直判不出来</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">今天提交</span>
|
||||
<b>{{ info.todaySubmissionCount }} 次</b>
|
||||
<button
|
||||
v-if="info.todaySystemErrorCount"
|
||||
type="button"
|
||||
class="link bad-note"
|
||||
@click="openSystemErrors"
|
||||
>
|
||||
系统错误 {{ info.todaySystemErrorCount }} 次 →
|
||||
</button>
|
||||
<span v-else class="note">系统错误 0 次</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">现在在线</span>
|
||||
<b>{{ info.onlineCount }} 人</b>
|
||||
<span class="note">5 分钟内有动静的</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">正在上课</span>
|
||||
<b>{{ info.activeClass ? classLabel(info.activeClass) : "没有" }}</b>
|
||||
<router-link
|
||||
v-if="info.activeClass"
|
||||
class="link"
|
||||
:to="{ name: 'classroom board', query: { className: info.activeClass } }"
|
||||
>
|
||||
去课堂看板 →
|
||||
</router-link>
|
||||
<span v-else class="note">最近两小时没有哪个班在交题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div v-if="info" class="site">
|
||||
<component
|
||||
:is="tile.to ? 'router-link' : 'div'"
|
||||
v-for="tile in siteTiles"
|
||||
:key="tile.label"
|
||||
:to="tile.to"
|
||||
class="site-tile"
|
||||
:class="{ clickable: tile.to }"
|
||||
>
|
||||
<span class="label">{{ tile.label }}</span>
|
||||
<b>{{ tile.value }}</b>
|
||||
<span class="note">{{ tile.note }}</span>
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<div v-if="info" class="chart-card">
|
||||
<div class="chart-head">
|
||||
<b>最近 30 天每天交了几次</b>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<Bar :key="chartKey" :data="dailyData" :options="dailyOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts">
|
||||
<n-button v-for="it in shortcuts" :key="it.to" size="large" @click="router.push(it.to)">
|
||||
{{ it.label }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="lucky">
|
||||
<b class="lucky-title">抽一个人</b>
|
||||
<n-select
|
||||
v-model:value="luckyClass"
|
||||
:options="classOptions"
|
||||
filterable
|
||||
placeholder="选班级"
|
||||
aria-label="班级"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
|
||||
{{ luckyGuy }}
|
||||
</span>
|
||||
<n-button type="primary" :disabled="!luckyClass || isRolling" @click="draw">
|
||||
{{ luckyGuy ? "再抽一个" : "抽" }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats {
|
||||
margin-bottom: 16px;
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.label,
|
||||
.note {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.tile b {
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tile b.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.tile b.bad,
|
||||
.bad-note {
|
||||
color: v-bind("theme.errorColor");
|
||||
}
|
||||
|
||||
.bad-note {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.primaryColor");
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.site {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.site-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: v-bind("theme.actionColor");
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-tile b {
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.site-tile.clickable:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.chart-head {
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lucky {
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.lucky-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lucky-name {
|
||||
min-width: 120px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
@@ -236,4 +379,11 @@ watch(showModal, (v) => {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tiles,
|
||||
.site {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user