Compare commits
7
Commits
6e6816436e
...
ee649b81b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee649b81b1 | ||
|
|
c33b084788 | ||
|
|
6470f56f87 | ||
|
|
bd3a57468f | ||
|
|
b93d4d83a1 | ||
|
|
56071c71bd | ||
|
|
1d406a50e4 |
No files matched your search
@@ -60,7 +60,8 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
|
|||||||
.from(schema.announcement)
|
.from(schema.announcement)
|
||||||
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.orderBy(desc(schema.announcement.createTime))
|
// 置顶的在最前,和学生在首页看到的顺序一样
|
||||||
|
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset),
|
.offset(offset),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
import { count, countDistinct, desc, eq, gte, ilike, inArray, isNull, not, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
@@ -21,7 +21,12 @@ import { publishConfigUpdate } from "../../events"
|
|||||||
import { failure, parseBody, success } from "../../http"
|
import { failure, parseBody, success } from "../../http"
|
||||||
import { sniffImageExtension } from "../../services/image"
|
import { sniffImageExtension } from "../../services/image"
|
||||||
import { getWebsiteOptions } from "../../services/options"
|
import { getWebsiteOptions } from "../../services/options"
|
||||||
import { dayStart } from "../../time"
|
import { calendarDay, dayStart, localTime, termStart, weekStart } from "../../time"
|
||||||
|
import { STUDENT_ROLES } from "@oj2/contract"
|
||||||
|
import { TEACHER_ROLES } from "../helpers"
|
||||||
|
import { onlineUserIds } from "../../auth/presence"
|
||||||
|
import { JudgeStatus } from "../../judge/status"
|
||||||
|
import { suggestActiveClass } from "../classroom"
|
||||||
import { classPrefixCondition, queryInteger } from "../helpers"
|
import { classPrefixCondition, queryInteger } from "../helpers"
|
||||||
|
|
||||||
export const adminConfRoutes = new Hono<AppEnv>()
|
export const adminConfRoutes = new Hono<AppEnv>()
|
||||||
@@ -191,10 +196,16 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
|||||||
|
|
||||||
adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const [[users], [submissions], [contests], [servers]] = await Promise.all([
|
const [[users], [submissions], [contests], [servers], online, activeClass] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.user),
|
db.select({ value: count() }).from(schema.user),
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({
|
||||||
|
value: count(),
|
||||||
|
systemErrors: sql<number>`count(*) filter (where ${eq(
|
||||||
|
schema.submission.result,
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
)})`.mapWith(Number),
|
||||||
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
.where(gte(schema.submission.createTime, dayStart())),
|
.where(gte(schema.submission.createTime, dayStart())),
|
||||||
db
|
db
|
||||||
@@ -205,13 +216,76 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
|||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(schema.judgeServer)
|
.from(schema.judgeServer)
|
||||||
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
||||||
|
onlineUserIds(),
|
||||||
|
suggestActiveClass(),
|
||||||
])
|
])
|
||||||
|
const thirtyDaysAgo = dayStart(Date.now() - 29 * 86_400_000)
|
||||||
|
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
||||||
|
const [[roles], [problems], [subs], [contestTotal], [setTotal], [tutorialTotal], [week], daily] =
|
||||||
|
await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
students: sql<number>`count(*) filter (where ${inArray(schema.user.adminType, [
|
||||||
|
...STUDENT_ROLES,
|
||||||
|
])})`.mapWith(Number),
|
||||||
|
teachers: sql<number>`count(*) filter (where ${inArray(schema.user.adminType, [
|
||||||
|
...TEACHER_ROLES,
|
||||||
|
])})`.mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(schema.user),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
total: count(),
|
||||||
|
visible: sql<number>`count(*) filter (where ${schema.problem.visible})`.mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(isNull(schema.problem.contestId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
total: count(),
|
||||||
|
term: sql<number>`count(*) filter (where ${gte(
|
||||||
|
schema.submission.createTime,
|
||||||
|
termStart(),
|
||||||
|
)})`.mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(schema.submission),
|
||||||
|
db.select({ value: count() }).from(schema.contest),
|
||||||
|
db.select({ value: count() }).from(schema.problemset),
|
||||||
|
db.select({ value: count() }).from(schema.tutorial),
|
||||||
|
db
|
||||||
|
.select({ value: countDistinct(schema.submission.userId) })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(gte(schema.submission.createTime, weekStart())),
|
||||||
|
db
|
||||||
|
.select({ day, count: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(gte(schema.submission.createTime, thirtyDaysAgo))
|
||||||
|
.groupBy(day),
|
||||||
|
])
|
||||||
|
const byDay = new Map(daily.map((row) => [row.day, row.count]))
|
||||||
|
const days = Array.from({ length: 30 }, (_, i) => calendarDay(Date.now() - (29 - i) * 86_400_000))
|
||||||
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
||||||
return success(c, {
|
return success(c, {
|
||||||
userCount: users?.value ?? 0,
|
userCount: users?.value ?? 0,
|
||||||
todaySubmissionCount: submissions?.value ?? 0,
|
todaySubmissionCount: submissions?.value ?? 0,
|
||||||
recentContestCount: contests?.value ?? 0,
|
recentContestCount: contests?.value ?? 0,
|
||||||
judgeServerCount: servers?.value ?? 0,
|
judgeServerCount: servers?.value ?? 0,
|
||||||
|
todaySystemErrorCount: submissions?.systemErrors ?? 0,
|
||||||
|
onlineCount: online.size,
|
||||||
|
activeClass: activeClass ?? null,
|
||||||
|
site: {
|
||||||
|
students: roles?.students ?? 0,
|
||||||
|
teachers: roles?.teachers ?? 0,
|
||||||
|
problems: problems?.total ?? 0,
|
||||||
|
visibleProblems: problems?.visible ?? 0,
|
||||||
|
submissions: subs?.total ?? 0,
|
||||||
|
termSubmissions: subs?.term ?? 0,
|
||||||
|
contests: contestTotal?.value ?? 0,
|
||||||
|
problemSets: setTotal?.value ?? 0,
|
||||||
|
tutorials: tutorialTotal?.value ?? 0,
|
||||||
|
weekActive: week?.value ?? 0,
|
||||||
|
},
|
||||||
|
daily: days.map((d) => ({ day: d, count: byDay.get(d) ?? 0 })),
|
||||||
} satisfies DashboardInfo)
|
} satisfies DashboardInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
|
|||||||
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
|
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
|
||||||
* 的班悄悄留在框里,老师看的整个是别人的班)。
|
* 的班悄悄留在框里,老师看的整个是别人的班)。
|
||||||
*/
|
*/
|
||||||
async function suggestActiveClass() {
|
export async function suggestActiveClass() {
|
||||||
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
|
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
|
||||||
const activity = activityRows({
|
const activity = activityRows({
|
||||||
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
|
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
|
||||||
|
|||||||
@@ -16,87 +16,87 @@ export interface AchievementMetric {
|
|||||||
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||||
{
|
{
|
||||||
key: "accepted_count",
|
key: "accepted_count",
|
||||||
name: "AC 题目数",
|
name: "做对的题数",
|
||||||
helpText: "去重后通过的题目数量(不含比赛)",
|
helpText: "去重后通过的题目数量(不含比赛)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "mid_ac_count",
|
key: "mid_ac_count",
|
||||||
name: "中等题 AC 数",
|
name: "做对的中等题",
|
||||||
helpText: "去重后通过的中等难度题目数(不含比赛)",
|
helpText: "去重后通过的中等难度题目数(不含比赛)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "hard_ac_count",
|
key: "hard_ac_count",
|
||||||
name: "困难题 AC 数",
|
name: "做对的困难题",
|
||||||
helpText: "去重后通过的困难题目数(不含比赛)",
|
helpText: "去重后通过的困难题目数(不含比赛)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "submission_count",
|
key: "submission_count",
|
||||||
name: "提交总数",
|
name: "提交次数",
|
||||||
helpText: "提交次数(不含比赛)",
|
helpText: "提交次数(不含比赛)",
|
||||||
},
|
},
|
||||||
{ key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" },
|
{ key: "active_days", name: "有提交的天数", helpText: "有过提交的累计天数" },
|
||||||
{
|
{
|
||||||
key: "max_ac_streak_days",
|
key: "max_ac_streak_days",
|
||||||
name: "最长连续 AC 天数",
|
name: "连续做对的天数",
|
||||||
helpText: "连续每天至少 AC 一题的最长天数",
|
helpText: "连续每天至少 AC 一题的最长天数",
|
||||||
},
|
},
|
||||||
// 按周不按天:学生跟着课表来,2025 秋最长连续交题天数的中位数是 1 天、连续 7 天的
|
// 按周不按天:学生跟着课表来,2025 秋最长连续交题天数的中位数是 1 天、连续 7 天的
|
||||||
// 只有 3 人,按天的「坚持一周」基本拿不到;按周的连续 2/4/8 周分别约 73%/49%/8%
|
// 只有 3 人,按天的「坚持一周」基本拿不到;按周的连续 2/4/8 周分别约 73%/49%/8%
|
||||||
{
|
{
|
||||||
key: "max_ac_week_streak",
|
key: "max_ac_week_streak",
|
||||||
name: "最长连续新通过周数",
|
name: "连续有新做对的周数",
|
||||||
helpText: "连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)",
|
helpText: "连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)",
|
||||||
},
|
},
|
||||||
{ key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
|
{ key: "languages_used", name: "用过几种语言", helpText: "用过多少种编程语言" },
|
||||||
{
|
{
|
||||||
key: "contest_joined",
|
key: "contest_joined",
|
||||||
name: "参赛场次",
|
name: "参加比赛",
|
||||||
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
|
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
|
||||||
},
|
},
|
||||||
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
|
{ key: "badge_count", name: "题单奖章", helpText: "获得的题单奖章数量" },
|
||||||
{
|
{
|
||||||
key: "problemset_completed",
|
key: "problemset_completed",
|
||||||
name: "完成题单数",
|
name: "做完的题单",
|
||||||
helpText: "完成的题单数量",
|
helpText: "完成的题单数量",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "first_try_ac_count",
|
key: "first_try_ac_count",
|
||||||
name: "一发入魂次数",
|
name: "一次就对的题",
|
||||||
helpText: "首次提交即通过的次数",
|
helpText: "首次提交即通过的次数",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "midnight_submissions",
|
key: "midnight_submissions",
|
||||||
name: "凌晨提交次数",
|
name: "凌晨提交",
|
||||||
helpText: "0:00–5:00 之间的提交次数",
|
helpText: "0:00–5:00 之间的提交次数",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "early_bird_submissions",
|
key: "early_bird_submissions",
|
||||||
name: "早起提交次数",
|
name: "早起提交",
|
||||||
helpText: "5:00–7:00 之间的提交次数",
|
helpText: "5:00–7:00 之间的提交次数",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "compile_error_count",
|
key: "compile_error_count",
|
||||||
name: "编译错误次数",
|
name: "编译错误",
|
||||||
helpText: "累计编译错误的次数",
|
helpText: "累计编译错误的次数",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "max_wa_before_ac",
|
key: "max_wa_before_ac",
|
||||||
name: "屡败屡战",
|
name: "错了几次才对",
|
||||||
helpText: "单题失败最多多少次后终于通过",
|
helpText: "单题失败最多多少次后终于通过",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "max_ac_in_one_day",
|
key: "max_ac_in_one_day",
|
||||||
name: "单日最多 AC",
|
name: "一天做对最多",
|
||||||
helpText: "一天之内最多通过多少题",
|
helpText: "一天之内最多通过多少题",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "max_code_lines",
|
key: "max_code_lines",
|
||||||
name: "最长代码行数",
|
name: "最长的代码行数",
|
||||||
helpText: "做对的提交里最长的代码有多少行(空行不算,不含比赛)",
|
helpText: "做对的提交里最长的代码有多少行(空行不算,不含比赛)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "achievement_unlocked_count",
|
key: "achievement_unlocked_count",
|
||||||
name: "已解锁成就数",
|
name: "拿到的成就数",
|
||||||
helpText: "已解锁的成就数量(不含白金档和已下架的)",
|
helpText: "已解锁的成就数量(不含白金档和已下架的)",
|
||||||
meta: true,
|
meta: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
updateAchievement,
|
updateAchievement,
|
||||||
type AdminAchievement,
|
type AdminAchievement,
|
||||||
type MetricOption,
|
type MetricOption,
|
||||||
|
deleteAchievement,
|
||||||
} from "admin/api"
|
} from "admin/api"
|
||||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import { RARITY_LABEL } from "utils/constants"
|
import { RARITY_LABEL } from "utils/constants"
|
||||||
@@ -20,6 +21,27 @@ const props = defineProps<{
|
|||||||
editing: AdminAchievement | null
|
editing: AdminAchievement | null
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
/** 删除放在编辑框左下角(列表改成阶梯之后,每一档上只留「点了就编辑」) */
|
||||||
|
function confirmDelete() {
|
||||||
|
const target = props.editing
|
||||||
|
if (!target) return
|
||||||
|
dialog.warning({
|
||||||
|
title: `删除「${target.name}」?`,
|
||||||
|
content: target.unlockCount
|
||||||
|
? `${target.unlockCount} 人已经拿到了,删掉会连他们的记录一起删。只是不想再发,关掉「上架」就行。`
|
||||||
|
: "删掉就找不回来了。",
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "不删",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await deleteAchievement(target.id)
|
||||||
|
message.success("删掉了")
|
||||||
|
emit("update:show", false)
|
||||||
|
emit("saved")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const metrics = ref<MetricOption[]>([])
|
const metrics = ref<MetricOption[]>([])
|
||||||
@@ -42,9 +64,7 @@ function emptyForm() {
|
|||||||
|
|
||||||
const form = ref(emptyForm())
|
const form = ref(emptyForm())
|
||||||
|
|
||||||
const metricOptions = computed(() =>
|
const metricOptions = computed(() => metrics.value.map((m) => ({ label: m.name, value: m.key })))
|
||||||
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const metricHelp = computed(
|
const metricHelp = computed(
|
||||||
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
|
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
|
||||||
@@ -155,6 +175,9 @@ async function save() {
|
|||||||
</n-form>
|
</n-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
|
<n-button v-if="editing" quaternary type="error" class="delete" @click="confirmDelete">
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
<n-button @click="emit('update:show', false)">取消</n-button>
|
<n-button @click="emit('update:show', false)">取消</n-button>
|
||||||
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
|
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -163,6 +186,10 @@ async function save() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.delete {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-preview {
|
.icon-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,33 +1,71 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { NButton, NFlex } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { deleteAchievement, getAdminAchievements, type AdminAchievement } from "admin/api"
|
import {
|
||||||
|
getAdminAchievements,
|
||||||
|
getBaseInfo,
|
||||||
|
getMetricOptions,
|
||||||
|
type AdminAchievement,
|
||||||
|
type MetricOption,
|
||||||
|
} from "admin/api"
|
||||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import AchievementModal from "./components/AchievementModal.vue"
|
import AchievementModal from "./components/AchievementModal.vue"
|
||||||
import { RARITY_LABEL } from "utils/constants"
|
import { RARITY_LABEL } from "utils/constants"
|
||||||
|
|
||||||
const message = useMessage()
|
/**
|
||||||
const dialog = useDialog()
|
* 后台成就(设计稿「我的成就重设计」AchAdmin):按指标分组,一组一行排成阶梯 ——
|
||||||
|
* 「做对的题数」初出茅庐 ≥1 → 小有所成 ≥10 → … → 二百大关 ≥200。每档写名字、门槛、
|
||||||
|
* 解锁人数和占全部学生的比例;隐藏的写「隐藏」,下架的变淡写「下架了」;点一档就编辑
|
||||||
|
* (删除在编辑框左下角)。
|
||||||
|
*
|
||||||
|
* 原来是一张平铺的表,同一个指标的几档散在各处,看不出阶梯,也看不出哪一档太难没人拿到
|
||||||
|
*/
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const list = ref<AdminAchievement[]>([])
|
const list = ref<AdminAchievement[]>([])
|
||||||
|
const metrics = ref<MetricOption[]>([])
|
||||||
|
const students = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const editing = ref<AdminAchievement | null>(null)
|
const editing = ref<AdminAchievement | null>(null)
|
||||||
|
|
||||||
// 下架的只在有的时候才提,全部上架时标题不啰嗦
|
const offline = computed(() => list.value.filter((a) => !a.visible).length)
|
||||||
const title = computed(() => {
|
|
||||||
if (!list.value.length) return "成就管理"
|
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */
|
||||||
const offline = list.value.filter((a) => !a.visible).length
|
const groups = computed(() => {
|
||||||
return offline
|
const byMetric = new Map<string, AdminAchievement[]>()
|
||||||
? `成就管理(${list.value.length} 条,${offline} 条下架)`
|
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
|
||||||
: `成就管理(${list.value.length} 条)`
|
const order = metrics.value.map((m) => m.key)
|
||||||
|
return [...byMetric.entries()]
|
||||||
|
.sort(
|
||||||
|
([a], [b]) =>
|
||||||
|
(order.indexOf(a) === -1 ? 99 : order.indexOf(a)) -
|
||||||
|
(order.indexOf(b) === -1 ? 99 : order.indexOf(b)),
|
||||||
|
)
|
||||||
|
.map(([metric, items]) => ({
|
||||||
|
metric,
|
||||||
|
name: items[0]?.metricName ?? metric,
|
||||||
|
items: [...items].sort((x, y) => x.threshold - y.threshold),
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function percent(a: AdminAchievement) {
|
||||||
|
if (!students.value) return ""
|
||||||
|
const value = (a.unlockCount / students.value) * 100
|
||||||
|
return value > 0 && value < 1 ? "<1%" : `${Math.round(value)}%`
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getAdminAchievements()
|
const [res, options, info] = await Promise.all([
|
||||||
|
getAdminAchievements(),
|
||||||
|
metrics.value.length ? Promise.resolve(metrics.value) : getMetricOptions(),
|
||||||
|
students.value ? Promise.resolve(null) : getBaseInfo().catch(() => null),
|
||||||
|
])
|
||||||
list.value = res
|
list.value = res
|
||||||
|
metrics.value = options
|
||||||
|
if (info) students.value = info.site.students
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -43,85 +81,134 @@ function edit(row: AdminAchievement) {
|
|||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(row: AdminAchievement) {
|
|
||||||
dialog.warning({
|
|
||||||
title: "删除成就",
|
|
||||||
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
|
|
||||||
positiveText: "删除",
|
|
||||||
negativeText: "取消",
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
await deleteAchievement(row.id)
|
|
||||||
message.success("已删除")
|
|
||||||
load()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminAchievement>[] = [
|
|
||||||
{
|
|
||||||
title: "图标",
|
|
||||||
key: "icon",
|
|
||||||
width: 60,
|
|
||||||
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
|
|
||||||
},
|
|
||||||
{ title: "名称", key: "name" },
|
|
||||||
{
|
|
||||||
title: "稀有度",
|
|
||||||
key: "rarity",
|
|
||||||
width: 90,
|
|
||||||
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
|
|
||||||
},
|
|
||||||
{ title: "指标", key: "metricName" },
|
|
||||||
{
|
|
||||||
title: "条件",
|
|
||||||
key: "threshold",
|
|
||||||
width: 110,
|
|
||||||
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "隐藏",
|
|
||||||
key: "hidden",
|
|
||||||
width: 70,
|
|
||||||
render: (row) => (row.hidden ? "是" : "—"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "上架",
|
|
||||||
key: "visible",
|
|
||||||
width: 70,
|
|
||||||
render: (row) => (row.visible ? "是" : "否"),
|
|
||||||
},
|
|
||||||
{ title: "已解锁人数", key: "unlockCount", width: 110 },
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
key: "actions",
|
|
||||||
width: 130,
|
|
||||||
render: (row) =>
|
|
||||||
h(NFlex, { size: 8 }, () => [
|
|
||||||
h(NButton, { text: true, type: "primary", onClick: () => edit(row) }, () => "编辑"),
|
|
||||||
h(NButton, { text: true, type: "error", onClick: () => remove(row) }, () => "删除"),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader
|
<PageHeader title="成就">
|
||||||
:title="title"
|
<template #extra>
|
||||||
description="「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。"
|
<n-text depth="3">
|
||||||
>
|
{{ list.length }} 个<template v-if="offline"> · {{ offline }} 个下架了</template>
|
||||||
|
</n-text>
|
||||||
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-button type="primary" @click="create">新建成就</n-button>
|
<n-button type="primary" @click="create">新建成就</n-button>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-data-table
|
<n-spin :show="loading">
|
||||||
:loading="loading"
|
<div class="groups">
|
||||||
:data="list"
|
<div v-for="group in groups" :key="group.metric" class="group">
|
||||||
:columns="columns"
|
<span class="metric">{{ group.name }}</span>
|
||||||
:row-key="(row: AdminAchievement) => row.id"
|
<div class="steps">
|
||||||
/>
|
<template v-for="(a, i) in group.items" :key="a.id">
|
||||||
|
<span v-if="i" class="arrow" aria-hidden="true">→</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
:class="{ off: !a.visible }"
|
||||||
|
:title="`${RARITY_LABEL[a.rarity] ?? a.rarity} · 点了编辑`"
|
||||||
|
@click="edit(a)"
|
||||||
|
>
|
||||||
|
<AchievementIcon :icon="a.icon" :size="26" />
|
||||||
|
<span class="step-text">
|
||||||
|
<b>
|
||||||
|
{{ a.name }}
|
||||||
|
<span class="muted small"
|
||||||
|
>{{ a.operator === "gte" ? "≥" : "≤" }} {{ a.threshold }}</span
|
||||||
|
>
|
||||||
|
</b>
|
||||||
|
<span class="small">
|
||||||
|
{{ a.unlockCount }} 人
|
||||||
|
<span v-if="percent(a)" class="muted">· {{ percent(a) }}</span>
|
||||||
|
<span v-if="a.hidden" class="muted"> 隐藏</span>
|
||||||
|
<span v-if="!a.visible" class="off-note"> 下架了</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
|
||||||
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
|
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
width: 150px;
|
||||||
|
flex: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid v-bind("theme.dividerColor");
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:hover {
|
||||||
|
border-color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.off {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-text b {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.off-note {
|
||||||
|
color: v-bind("theme.errorColor");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteAnnouncement } from "admin/api"
|
import { deleteAnnouncement } from "admin/api"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公告一行的操作:编辑、置顶、删除直接摆出来(用户说的:不要收进 ⋯)
|
||||||
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
announcementID: number
|
announcementID: number
|
||||||
|
top: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits(["deleted"])
|
const emit = defineEmits<{ deleted: []; toggleTop: [] }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
function goEdit() {
|
function goEdit() {
|
||||||
router.push({
|
router.push({
|
||||||
@@ -17,21 +22,26 @@ function goEdit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
function confirmDelete() {
|
||||||
|
dialog.warning({
|
||||||
|
title: "删除这条公告?",
|
||||||
|
content: "删掉就找不回来了。只是不想让学生看到的话,关掉「学生看得到」就行。",
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "不删",
|
||||||
|
onPositiveClick: async () => {
|
||||||
await deleteAnnouncement(props.announcementID)
|
await deleteAnnouncement(props.announcementID)
|
||||||
message.success("删除成功")
|
message.success("删掉了")
|
||||||
emit("deleted")
|
emit("deleted")
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<n-flex :size="6" :wrap="false">
|
||||||
<n-button size="small" type="success" secondary @click="goEdit"> 编辑 </n-button>
|
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||||
<n-popconfirm @positive-click="handleDelete">
|
<n-button size="small" @click="emit('toggleTop')">
|
||||||
<template #trigger>
|
{{ props.top ? "取消置顶" : "置顶" }}
|
||||||
<n-button size="small" type="error" secondary>删除</n-button>
|
</n-button>
|
||||||
</template>
|
<n-button size="small" quaternary type="error" @click="confirmDelete">删除</n-button>
|
||||||
确定删除这条公告吗?
|
|
||||||
</n-popconfirm>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
|
||||||
@@ -60,9 +60,9 @@ async function submit() {
|
|||||||
try {
|
try {
|
||||||
await api!(announcement)
|
await api!(announcement)
|
||||||
if (route.name === "admin announcement create") {
|
if (route.name === "admin announcement create") {
|
||||||
message.success("成功新建公告 💐")
|
message.success("发好了")
|
||||||
} else {
|
} else {
|
||||||
message.success("修改已保存")
|
message.success("改好了")
|
||||||
}
|
}
|
||||||
router.push({ name: "admin announcement list" })
|
router.push({ name: "admin announcement list" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -74,34 +74,51 @@ onMounted(init)
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
:title="route.name === 'admin announcement create' ? '新建公告' : '编辑公告'"
|
:title="route.name === 'admin announcement create' ? '写一条公告' : '改公告'"
|
||||||
:back="{ name: 'admin announcement list' }"
|
:back="{ name: 'admin announcement list' }"
|
||||||
/>
|
>
|
||||||
<n-form inline>
|
<template #actions>
|
||||||
<n-form-item label="标题">
|
|
||||||
<n-input class="contestTitle" v-model:value="announcement.title" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="标签">
|
|
||||||
<n-select class="select" v-model:value="announcement.tag" :options="tags" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="可见">
|
|
||||||
<n-switch v-model:value="announcement.visible" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="置顶">
|
|
||||||
<n-switch v-model:value="announcement.top" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<TextEditor v-if="ready" title="正文" v-model:value="announcement.content" :min-height="200" />
|
|
||||||
<n-flex style="margin-bottom: 100px" justify="end">
|
|
||||||
<n-button type="primary" @click="submit">保存</n-button>
|
<n-button type="primary" @click="submit">保存</n-button>
|
||||||
</n-flex>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
<!-- 标题、类型、置顶、学生看得到排一行;保存在右上角,不用滚到正文底下去点 -->
|
||||||
|
<div class="meta">
|
||||||
|
<n-input
|
||||||
|
v-model:value="announcement.title"
|
||||||
|
placeholder="标题"
|
||||||
|
size="large"
|
||||||
|
class="title"
|
||||||
|
aria-label="标题"
|
||||||
|
/>
|
||||||
|
<n-radio-group v-model:value="announcement.tag" aria-label="类型">
|
||||||
|
<n-radio-button v-for="t in tags" :key="String(t.value)" :value="t.value">
|
||||||
|
{{ t.label }}
|
||||||
|
</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
<label class="switch">置顶 <n-switch v-model:value="announcement.top" /></label>
|
||||||
|
<label class="switch">学生看得到 <n-switch v-model:value="announcement.visible" /></label>
|
||||||
|
</div>
|
||||||
|
<TextEditor v-if="ready" title="正文" v-model:value="announcement.content" :min-height="320" />
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.select {
|
.meta {
|
||||||
width: 100px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contestTitle {
|
.title {
|
||||||
width: 400px;
|
flex: 1 1 320px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch, useThemeVars } from "naive-ui"
|
||||||
|
import { RouterLink } from "vue-router"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { AdminAnnouncementListItem } from "utils/types"
|
import type { AdminAnnouncementListItem } from "utils/types"
|
||||||
@@ -15,57 +16,77 @@ const query = reactive({
|
|||||||
})
|
})
|
||||||
const announcements = ref<AdminAnnouncementListItem[]>([])
|
const announcements = ref<AdminAnnouncementListItem[]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台公告列表(设计稿「后台公告」AnnAdmin):一行一条,标题(置顶挂标,公告 / 更新淡淡写在后面)、
|
||||||
|
* 发布、改过、学生看得到;编辑、置顶、删除直接摆出来。ID、作者(都是同一个人)、
|
||||||
|
* 精确到秒的两列时间拿掉
|
||||||
|
*/
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
||||||
{ title: "ID", key: "id", width: 60 },
|
|
||||||
{ title: "标题", key: "title", minWidth: 300 },
|
|
||||||
{ title: "标签", key: "tag", width: 80 },
|
|
||||||
{
|
{
|
||||||
title: "置顶",
|
title: "标题",
|
||||||
key: "top",
|
key: "title",
|
||||||
render: (row) => (row.top ? "置顶" : ""),
|
width: 360,
|
||||||
width: 80,
|
render: (row) =>
|
||||||
|
h("span", { class: "title-cell" }, [
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{
|
||||||
|
class: "title-link",
|
||||||
|
to: { name: "admin announcement edit", params: { announcementID: row.id } },
|
||||||
|
},
|
||||||
|
() => row.title,
|
||||||
|
),
|
||||||
|
row.top ? h("span", { class: "top" }, "置顶") : null,
|
||||||
|
h("span", { class: "muted small" }, row.tag),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "发布",
|
||||||
key: "createTime",
|
key: "createTime",
|
||||||
width: 180,
|
width: 120,
|
||||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => h("span", { class: "num" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "上次更新时间",
|
title: "改过",
|
||||||
key: "lastUpdateTime",
|
key: "lastUpdateTime",
|
||||||
width: 180,
|
width: 120,
|
||||||
render: (row) => parseTime(row.lastUpdateTime, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => {
|
||||||
|
const created = parseTime(row.createTime, "YYYY-MM-DD")
|
||||||
|
const updated = parseTime(row.lastUpdateTime, "YYYY-MM-DD")
|
||||||
|
return h("span", { class: "num muted" }, updated === created ? "—" : updated)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "作者",
|
title: "学生看得到",
|
||||||
key: "createdBy",
|
|
||||||
render: (row) => row.createdBy.username,
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "可见",
|
|
||||||
key: "visible",
|
key: "visible",
|
||||||
|
width: 100,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(NSwitch, {
|
h(NSwitch, {
|
||||||
value: row.visible,
|
value: row.visible,
|
||||||
size: "small",
|
size: "small",
|
||||||
rubberBand: false,
|
rubberBand: false,
|
||||||
onUpdateValue: () => toggleVisible(row),
|
onUpdateValue: () => toggle(row, "visible"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "选项",
|
title: "",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 140,
|
minWidth: 240,
|
||||||
render: (row) => h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
|
render: (row) =>
|
||||||
|
h(Actions, {
|
||||||
|
announcementID: row.id,
|
||||||
|
top: row.top,
|
||||||
|
onDeleted: listAnnouncements,
|
||||||
|
onToggleTop: () => toggle(row, "top"),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
||||||
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
||||||
async function toggleVisible(announcement: AdminAnnouncementListItem) {
|
async function toggle(announcement: AdminAnnouncementListItem, field: "visible" | "top") {
|
||||||
const next = !announcement.visible
|
|
||||||
try {
|
try {
|
||||||
const full = await getAnnouncement(announcement.id)
|
const full = await getAnnouncement(announcement.id)
|
||||||
await editAnnouncement({
|
await editAnnouncement({
|
||||||
@@ -73,12 +94,13 @@ async function toggleVisible(announcement: AdminAnnouncementListItem) {
|
|||||||
title: full.title,
|
title: full.title,
|
||||||
tag: full.tag,
|
tag: full.tag,
|
||||||
content: full.content,
|
content: full.content,
|
||||||
visible: next,
|
visible: field === "visible" ? !full.visible : full.visible,
|
||||||
top: full.top,
|
top: field === "top" ? !full.top : full.top,
|
||||||
})
|
})
|
||||||
announcement.visible = next
|
if (field === "top") listAnnouncements()
|
||||||
|
else announcement.visible = !full.visible
|
||||||
} catch {
|
} catch {
|
||||||
message.error("修改可见性失败")
|
message.error("没改成,刷新一下再试")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,13 +116,52 @@ watch(query, listAnnouncements, { deep: true })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader title="网站公告">
|
<PageHeader title="公告">
|
||||||
<template #actions>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-data-table striped :columns="columns" :data="announcements" />
|
<n-data-table class="ann-table" :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>
|
||||||
|
.ann-table :deep(.title-cell) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-table :deep(.title-link) {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-table :deep(.title-link:hover) {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-table :deep(.top) {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: v-bind("theme.warningColor");
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-table :deep(.muted) {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-table :deep(.small) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-table :deep(.num) {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import type { AdminContest } from "utils/types"
|
import type { AdminContest } from "utils/types"
|
||||||
import { cloneContest } from "../../api"
|
import { cloneContest } from "../../api"
|
||||||
|
|
||||||
@@ -22,14 +21,8 @@ function goClass() {
|
|||||||
window.open(`/contest/${props.contest.id}/class`, "_blank")
|
window.open(`/contest/${props.contest.id}/class`, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuOptions: DropdownOption[] = [
|
function openFront() {
|
||||||
{ label: "复制一份", key: "clone" },
|
window.open(`/contest/${props.contest.id}`, "_blank")
|
||||||
{ label: "在前台看", key: "view" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function onMenu(key: string) {
|
|
||||||
if (key === "clone") clone()
|
|
||||||
else if (key === "view") window.open(`/contest/${props.contest.id}`, "_blank")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clone() {
|
async function clone() {
|
||||||
@@ -46,16 +39,14 @@ async function clone() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex :size="4" justify="end" :wrap="false">
|
<!-- 操作直接摆出来,不收进 ⋯(用户说的) -->
|
||||||
|
<n-flex :size="4" :wrap="false">
|
||||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||||
<n-button size="small" @click="goClass">
|
<n-button size="small" @click="goClass">
|
||||||
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
|
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
|
<n-button size="small" @click="clone">复制一份</n-button>
|
||||||
<n-button size="small" quaternary aria-label="更多">
|
<n-button size="small" title="在前台打开这场比赛" @click="openFront">前台看</n-button>
|
||||||
<template #icon><Icon icon="ph:dots-three-bold" /></template>
|
|
||||||
</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
@@ -12,7 +12,7 @@ import Actions from "./components/Actions.vue"
|
|||||||
/**
|
/**
|
||||||
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
|
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
|
||||||
* 要密码的带锁)、开始时间和多长、状态、几道题、几人参加、出卷人、学生看得到;
|
* 要密码的带锁)、开始时间和多长、状态、几道题、几人参加、出卷人、学生看得到;
|
||||||
* 操作「编辑」「成绩 / 全班情况」+ ⋯。按开始时间排 —— 原来按创建时间,复制出来的旧比赛
|
* 操作「编辑」「成绩 / 全班情况」「复制一份」「在前台看」直接摆出来。按开始时间排 —— 原来按创建时间,复制出来的旧比赛
|
||||||
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
|
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
|
||||||
*/
|
*/
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
@@ -47,7 +47,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
{
|
{
|
||||||
title: "比赛",
|
title: "比赛",
|
||||||
key: "title",
|
key: "title",
|
||||||
minWidth: 220,
|
width: 260,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h("span", { class: "title-cell" }, [
|
h("span", { class: "title-cell" }, [
|
||||||
h(ContestTitle, { contest: row }),
|
h(ContestTitle, { contest: row }),
|
||||||
@@ -57,7 +57,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
{
|
{
|
||||||
title: "开始 · 多长",
|
title: "开始 · 多长",
|
||||||
key: "start_time",
|
key: "start_time",
|
||||||
width: 220,
|
width: 200,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h("span", { class: "num" }, [
|
h("span", { class: "num" }, [
|
||||||
parseTime(row.startTime, "YYYY-MM-DD HH:mm"),
|
parseTime(row.startTime, "YYYY-MM-DD HH:mm"),
|
||||||
@@ -67,7 +67,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 80,
|
width: 72,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(
|
h(
|
||||||
"span",
|
"span",
|
||||||
@@ -89,7 +89,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
{
|
{
|
||||||
title: "出卷",
|
title: "出卷",
|
||||||
key: "created_by",
|
key: "created_by",
|
||||||
width: 96,
|
width: 84,
|
||||||
ellipsis: { tooltip: true },
|
ellipsis: { tooltip: true },
|
||||||
render: (row) => row.createdBy.username,
|
render: (row) => row.createdBy.username,
|
||||||
},
|
},
|
||||||
@@ -108,7 +108,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
{
|
{
|
||||||
title: "",
|
title: "",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 190,
|
minWidth: 270,
|
||||||
render: (row) => h(Actions, { contest: row }),
|
render: (row) => h(Actions, { contest: row }),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { errorCode, errorMessage } from "utils/api"
|
import { errorCode, errorMessage } from "utils/api"
|
||||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
||||||
import download from "utils/download"
|
import download from "utils/download"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目列表一行的操作(设计稿「后台题目列表」PlA):常用的「编辑」常驻,其余收进 ⋯ ——
|
* 题目列表一行的操作:编辑、在前台看、下载测试数据、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||||
* 原来是四颗彩色按钮(编辑 / 查看 / 删除 / 下载)并排,1366 宽的屏上把「可见」开关挤出了屏幕。
|
* 原来是四颗彩色按钮,现在一律素色,删除用浅色的危险按钮。
|
||||||
* 有人交过的题删不掉(服务端也拦),「删除」直接灰掉并写明为什么,不用点了才知道
|
* 有人交过的题删不掉(服务端也拦),「删除」直接灰掉、鼠标放上去写明为什么
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID: number
|
problemID: number
|
||||||
@@ -69,21 +68,7 @@ function goCheck() {
|
|||||||
window.open(data.href, "_blank")
|
window.open(data.href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuOptions = computed<DropdownOption[]>(() => [
|
function confirmDelete() {
|
||||||
{ label: "在前台看", key: "view" },
|
|
||||||
{ label: "下载测试数据", key: "download" },
|
|
||||||
...(isContestProblem.value ? [{ label: "转成公开题", key: "public" }] : []),
|
|
||||||
{ type: "divider", key: "divider" },
|
|
||||||
props.triedUsers
|
|
||||||
? { label: `删除(有 ${props.triedUsers} 人交过)`, key: "delete", disabled: true }
|
|
||||||
: { label: "删除", key: "delete" },
|
|
||||||
])
|
|
||||||
|
|
||||||
function onMenu(key: string) {
|
|
||||||
if (key === "view") goCheck()
|
|
||||||
else if (key === "download") downloads()
|
|
||||||
else if (key === "public") openMakePublicModal()
|
|
||||||
else if (key === "delete") {
|
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: `删除 ${props.problemDisplayID}?`,
|
title: `删除 ${props.problemDisplayID}?`,
|
||||||
content: "删掉就找不回来了。",
|
content: "删掉就找不回来了。",
|
||||||
@@ -91,7 +76,6 @@ function onMenu(key: string) {
|
|||||||
negativeText: "不删",
|
negativeText: "不删",
|
||||||
onPositiveClick: handleDeleteProblem,
|
onPositiveClick: handleDeleteProblem,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMakePublicModal() {
|
function openMakePublicModal() {
|
||||||
@@ -122,13 +106,24 @@ async function handleMakePublic() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex :size="4" justify="end" :wrap="false">
|
<n-flex :size="6" :wrap="false">
|
||||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||||
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
|
<n-button size="small" title="在前台打开这道题" @click="goCheck">前台看</n-button>
|
||||||
<n-button size="small" quaternary aria-label="更多">
|
<n-button size="small" title="下载测试数据" @click="downloads">测试数据</n-button>
|
||||||
<template #icon><Icon icon="ph:dots-three-bold" /></template>
|
<n-button v-if="isContestProblem" size="small" @click="openMakePublicModal">
|
||||||
|
转成公开题
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-dropdown>
|
<span :title="props.triedUsers ? `有 ${props.triedUsers} 人交过,删不掉` : undefined">
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
type="error"
|
||||||
|
:disabled="!!props.triedUsers"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
|
</span>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
<n-modal
|
<n-modal
|
||||||
|
|||||||
@@ -102,12 +102,18 @@ const visibleTabs = computed(() => [
|
|||||||
{ value: "0" as const, label: "藏着的", count: counts.value?.hidden },
|
{ value: "0" as const, label: "藏着的", count: counts.value?.hidden },
|
||||||
])
|
])
|
||||||
|
|
||||||
/** 知识点只摆前两个,多的写 +N,鼠标放上去看全部(P073 这类题挂了六个) */
|
/**
|
||||||
|
* 知识点:两个以内全摆,多了只摆第一个 + 「+N」,鼠标放上去看全部(P073 这类题挂了六个)。
|
||||||
|
* 列宽只放得下两个,摆两个再加 +N 的话 +N 会被截掉
|
||||||
|
*/
|
||||||
function renderTags(tags: string[]) {
|
function renderTags(tags: string[]) {
|
||||||
if (!tags.length) return h("span", { class: "muted" }, "—")
|
if (!tags.length) return h("span", { class: "muted" }, "—")
|
||||||
|
const shown = tags.length > 2 ? tags.slice(0, 1) : tags
|
||||||
return h("span", { class: "tags", title: tags.join("、") }, [
|
return h("span", { class: "tags", title: tags.join("、") }, [
|
||||||
...tags.slice(0, 2).map((tag) => h("span", { class: "tag", key: tag }, tag)),
|
...shown.map((tag) => h("span", { class: "tag", key: tag }, tag)),
|
||||||
tags.length > 2 ? h("span", { class: "muted more" }, `+${tags.length - 2}`) : null,
|
tags.length > shown.length
|
||||||
|
? h("span", { class: "muted more" }, `+${tags.length - shown.length}`)
|
||||||
|
: null,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,15 +152,17 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
|||||||
{
|
{
|
||||||
title: "题号",
|
title: "题号",
|
||||||
key: "_id",
|
key: "_id",
|
||||||
width: 84,
|
width: 70,
|
||||||
render: (row) => h("span", { class: "did", title: `内部编号 ${row.id}` }, row._id),
|
render: (row) => h("span", { class: "did", title: `内部编号 ${row.id}` }, row._id),
|
||||||
},
|
},
|
||||||
{ title: "题目", key: "title", minWidth: 220, ellipsis: false, render: renderTitle },
|
// 题目定宽:p90 也就 12 个字加一两个类型标签。原来 minWidth 让它吃掉整行剩余空间(用户嫌空太多),
|
||||||
{ title: "知识点", key: "tags", width: 190, render: (row) => renderTags(row.tags) },
|
// 剩下的宽度给最后的操作列
|
||||||
|
{ title: "题目", key: "title", width: 196, ellipsis: false, render: renderTitle },
|
||||||
|
{ title: "知识点", key: "tags", width: 158, render: (row) => renderTags(row.tags) },
|
||||||
{
|
{
|
||||||
title: "做对 / 做过",
|
title: "做对 / 做过",
|
||||||
key: "users",
|
key: "users",
|
||||||
width: 100,
|
width: 96,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
row.triedUsers
|
row.triedUsers
|
||||||
? h("span", { class: "num" }, [
|
? h("span", { class: "num" }, [
|
||||||
@@ -163,11 +171,11 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
|||||||
])
|
])
|
||||||
: h("span", { class: "muted" }, "没人做过"),
|
: h("span", { class: "muted" }, "没人做过"),
|
||||||
},
|
},
|
||||||
{ title: "出题人", key: "username", width: 100, ellipsis: { tooltip: true } },
|
{ title: "出题人", key: "username", width: 86, ellipsis: { tooltip: true } },
|
||||||
{
|
{
|
||||||
title: "创建",
|
title: "创建",
|
||||||
key: "create_time",
|
key: "create_time",
|
||||||
width: 96,
|
width: 100,
|
||||||
render: (row) => h("span", { class: "num muted" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
render: (row) => h("span", { class: "num muted" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -185,7 +193,7 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
|||||||
{
|
{
|
||||||
title: "",
|
title: "",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 104,
|
minWidth: 240,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
problemID: row.id,
|
problemID: row.id,
|
||||||
@@ -385,6 +393,11 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表头不折行:「做对 / 做过」「学生看得到」折成两行时整张表头变高 */
|
||||||
|
.problem-table :deep(.n-data-table-th) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.problem-table :deep(.did) {
|
.problem-table :deep(.did) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -409,10 +422,11 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
|||||||
}
|
}
|
||||||
|
|
||||||
.problem-table :deep(.tags) {
|
.problem-table :deep(.tags) {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problem-table :deep(.tag) {
|
.problem-table :deep(.tag) {
|
||||||
@@ -435,5 +449,6 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
|||||||
|
|
||||||
.problem-table :deep(.num) {
|
.problem-table :deep(.num) {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import type { AdminTag, TagCategory } from "utils/types"
|
import type { AdminTag, TagCategory } from "utils/types"
|
||||||
@@ -30,7 +29,8 @@ function openTagProblems(tag: AdminTag) {
|
|||||||
/**
|
/**
|
||||||
* 两栏:知识点 / 主题(设计稿「后台标签」TagA)。原来是一张宽表,每行一组单选按钮切分类、
|
* 两栏:知识点 / 主题(设计稿「后台标签」TagA)。原来是一张宽表,每行一组单选按钮切分类、
|
||||||
* 两颗按钮,ID 一列、页头一段说明。现在一行一个标签 + 挂了几道题(条的长短看出哪个用得多),
|
* 两颗按钮,ID 一列、页头一段说明。现在一行一个标签 + 挂了几道题(条的长短看出哪个用得多),
|
||||||
* 点名字看这个标签下的题,改名 / 换分类 / 删除收进 ⋯。还没归类的单独一条放最上面
|
* 点名字看这个标签下的题,改名 / 换分类 / 删除直接摆在行尾(用户说的:不要收进 ⋯)。
|
||||||
|
* 还没归类的单独一条放最上面
|
||||||
*/
|
*/
|
||||||
const columnsOf = computed(() => {
|
const columnsOf = computed(() => {
|
||||||
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
|
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
|
||||||
@@ -49,25 +49,6 @@ const columnsOf = computed(() => {
|
|||||||
})
|
})
|
||||||
const pendingTags = computed(() => visibleTags.value.filter((t) => !t.categoryConfirmed))
|
const pendingTags = computed(() => visibleTags.value.filter((t) => !t.categoryConfirmed))
|
||||||
|
|
||||||
function menuOf(tag: AdminTag): DropdownOption[] {
|
|
||||||
return [
|
|
||||||
{ label: "改名(同名就合并)", key: "rename" },
|
|
||||||
{ label: tag.category === "theme" ? "改成知识点" : "改成主题", key: "move" },
|
|
||||||
{ type: "divider", key: "divider" },
|
|
||||||
{
|
|
||||||
label: tag.problemCount ? `删除(${tag.problemCount} 道题会摘掉它)` : "删除",
|
|
||||||
key: "delete",
|
|
||||||
props: { style: `color: ${theme.value.errorColor}` },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMenu(tag: AdminTag, key: string) {
|
|
||||||
if (key === "rename") startEdit(tag)
|
|
||||||
else if (key === "move") changeCategory(tag, tag.category === "theme" ? "knowledge" : "theme")
|
|
||||||
else if (key === "delete") confirmDelete(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listTags() {
|
async function listTags() {
|
||||||
const res = await getTagAdminList(keyword.value)
|
const res = await getTagAdminList(keyword.value)
|
||||||
tags.value = res
|
tags.value = res
|
||||||
@@ -177,16 +158,17 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i
|
><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i
|
||||||
></span>
|
></span>
|
||||||
<span class="count">{{ tag.problemCount }} 道</span>
|
<span class="count">{{ tag.problemCount }} 道</span>
|
||||||
<n-dropdown
|
<n-button size="tiny" quaternary @click="startEdit(tag)">改名</n-button>
|
||||||
trigger="click"
|
<n-button
|
||||||
placement="bottom-end"
|
size="tiny"
|
||||||
:options="menuOf(tag)"
|
quaternary
|
||||||
@select="(key: string) => onMenu(tag, key)"
|
@click="changeCategory(tag, tag.category === 'theme' ? 'knowledge' : 'theme')"
|
||||||
>
|
>
|
||||||
<n-button size="small" quaternary aria-label="更多">
|
{{ tag.category === "theme" ? "改成知识点" : "改成主题" }}
|
||||||
<template #icon><Icon icon="ph:dots-three-bold" /></template>
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-dropdown>
|
<n-button size="tiny" quaternary type="error" @click="confirmDelete(tag)"
|
||||||
|
>删除</n-button
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +241,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
flex: 0 0 140px;
|
flex: 0 0 120px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
+329
-179
@@ -1,224 +1,367 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h, onMounted, reactive, ref, watch } from "vue"
|
import type { DashboardInfo } from "@oj2/contract"
|
||||||
import { useRouter } from "vue-router"
|
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from "chart.js"
|
||||||
import { NButton } from "naive-ui"
|
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 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 { useUserStore } from "shared/store/user"
|
||||||
import { getACRate } from "utils/functions"
|
import { SubmissionStatus } from "utils/constants"
|
||||||
import type { Rank } from "utils/types"
|
import { getBaseInfo, randomUser10 } from "../api"
|
||||||
import { getAdminUserRank, getBaseInfo, randomUser10 } from "../api"
|
|
||||||
|
|
||||||
const userCount = ref(0)
|
/**
|
||||||
const submissionCount = ref(0)
|
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
|
||||||
const contestCount = ref(0)
|
* 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、
|
||||||
const judgeServerCount = ref(0)
|
* 提交、比赛、题单、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次;
|
||||||
const userStore = useUserStore()
|
* 最下面是常用的四个入口和「抽一个人」。
|
||||||
|
*
|
||||||
|
* 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 ——
|
||||||
|
* 总用户数、近期比赛没人看,排名前台排名页有(还更好看)。「随机抽签」上课点名用,留着,
|
||||||
|
* 班级改成下拉选,默认就是正在上课的那个班
|
||||||
|
*/
|
||||||
|
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip)
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const { chartKey } = useChartTheme()
|
||||||
const router = useRouter()
|
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 luckyGuy = ref("")
|
||||||
const isRolling = ref(false)
|
const isRolling = ref(false)
|
||||||
const rollingNames = ref<string[]>([])
|
|
||||||
const pulseKey = ref(0)
|
const pulseKey = ref(0)
|
||||||
let rollingTimer: ReturnType<typeof setInterval> | null = null
|
let rollingTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let rollingStopper: ReturnType<typeof setTimeout> | 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() {
|
function stopRolling() {
|
||||||
if (rollingTimer) {
|
if (rollingTimer) clearInterval(rollingTimer)
|
||||||
clearInterval(rollingTimer)
|
if (rollingStopper) clearTimeout(rollingStopper)
|
||||||
rollingTimer = null
|
rollingTimer = null
|
||||||
}
|
|
||||||
if (rollingStopper) {
|
|
||||||
clearTimeout(rollingStopper)
|
|
||||||
rollingStopper = null
|
rollingStopper = null
|
||||||
}
|
|
||||||
isRolling.value = false
|
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()
|
stopRolling()
|
||||||
if (!rollingNames.value.length) return
|
|
||||||
isRolling.value = true
|
isRolling.value = true
|
||||||
const interval = 80
|
|
||||||
const duration = 2000
|
|
||||||
let index = 0
|
let index = 0
|
||||||
rollingTimer = setInterval(() => {
|
rollingTimer = setInterval(() => {
|
||||||
luckyGuy.value = rollingNames.value[index % rollingNames.value.length]
|
luckyGuy.value = names[index % names.length]!
|
||||||
index += 1
|
index += 1
|
||||||
}, interval)
|
}, 80)
|
||||||
rollingStopper = setTimeout(() => {
|
rollingStopper = setTimeout(() => {
|
||||||
stopRolling()
|
stopRolling()
|
||||||
luckyGuy.value = finalName
|
luckyGuy.value = names[names.length - 1]!
|
||||||
pulseKey.value += 1
|
pulseKey.value += 1
|
||||||
}, duration)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRandom() {
|
watch(luckyClass, () => {
|
||||||
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()
|
stopRolling()
|
||||||
luckyGuy.value = ""
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader :title="`你好,${userStore.user?.username ?? '管理员'}`">
|
<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">
|
<div v-if="info" class="tiles">
|
||||||
<n-gi>
|
<div class="tile">
|
||||||
<n-card size="small"><n-statistic label="总用户数" :value="userCount" /></n-card>
|
<span class="label">判题机</span>
|
||||||
</n-gi>
|
<b :class="info.judgeServerCount ? 'ok' : 'bad'">{{ info.judgeServerCount }} 台在线</b>
|
||||||
<n-gi>
|
<span v-if="!info.judgeServerCount" class="bad-note">学生交了会一直判不出来</span>
|
||||||
<n-card size="small"><n-statistic label="今日提交" :value="submissionCount" /></n-card>
|
</div>
|
||||||
</n-gi>
|
<div class="tile">
|
||||||
<n-gi>
|
<span class="label">今天提交</span>
|
||||||
<n-card size="small"><n-statistic label="近期比赛" :value="contestCount" /></n-card>
|
<b>{{ info.todaySubmissionCount }} 次</b>
|
||||||
</n-gi>
|
<button
|
||||||
<n-gi>
|
v-if="info.todaySystemErrorCount"
|
||||||
<!-- 判题机数量后端一直在下发,这里以前没显示 —— 判题机全掉线的时候,
|
type="button"
|
||||||
这一栏是 0,比学生喊「交了没反应」早得多 -->
|
class="link bad-note"
|
||||||
<n-card size="small">
|
@click="openSystemErrors"
|
||||||
<n-statistic label="在线判题机">
|
>
|
||||||
<n-text :type="judgeServerCount > 0 ? 'success' : 'error'">
|
系统错误 {{ info.todaySystemErrorCount }} 次 →
|
||||||
{{ judgeServerCount }}
|
</button>
|
||||||
</n-text>
|
<span v-else class="note">系统错误 0 次</span>
|
||||||
</n-statistic>
|
</div>
|
||||||
</n-card>
|
<div class="tile">
|
||||||
</n-gi>
|
<span class="label">现在在线</span>
|
||||||
</n-grid>
|
<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="班级排名">
|
<div v-if="info" class="site">
|
||||||
<template #header-extra>
|
<component
|
||||||
<n-flex align="center" :size="8">
|
:is="tile.to ? 'router-link' : 'div'"
|
||||||
<n-input
|
v-for="tile in siteTiles"
|
||||||
style="width: 160px"
|
:key="tile.label"
|
||||||
clearable
|
:to="tile.to"
|
||||||
v-model:value="query.classroom"
|
class="site-tile"
|
||||||
placeholder="班级前缀"
|
:class="{ clickable: tile.to }"
|
||||||
@keyup.enter="listRanks"
|
>
|
||||||
/>
|
<span class="label">{{ tile.label }}</span>
|
||||||
<n-button secondary type="primary" @click="listRanks">查询</n-button>
|
<b>{{ tile.value }}</b>
|
||||||
<n-button v-if="query.classroom" @click="getRandomModal">随机抽签</n-button>
|
<span class="note">{{ tile.note }}</span>
|
||||||
</n-flex>
|
</component>
|
||||||
</template>
|
</div>
|
||||||
<template v-if="data.length">
|
|
||||||
<n-data-table striped :data="data" :columns="columns" />
|
<div v-if="info" class="chart-card">
|
||||||
<Pagination :total="total" v-model:page="query.page" v-model:limit="query.limit" />
|
<div class="chart-head">
|
||||||
</template>
|
<b>最近 30 天每天交了几次</b>
|
||||||
<n-empty v-else description="输入班级前缀后查询" style="padding: 24px 0" />
|
</div>
|
||||||
</n-card>
|
<div class="chart">
|
||||||
<n-modal preset="card" title="猜猜看幸运儿是谁?" v-model:show="showModal" style="width: 400px">
|
<Bar :key="chartKey" :data="dailyData" :options="dailyOptions" />
|
||||||
<n-flex vertical justify="center" align="center">
|
</div>
|
||||||
<n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1>
|
</div>
|
||||||
<n-button block :disabled="isRolling" @click="getRandom">
|
|
||||||
{{ luckyGuy ? "再来一次" : "开始抽签" }}
|
<div class="shortcuts">
|
||||||
|
<n-button v-for="it in shortcuts" :key="it.to" size="large" @click="router.push(it.to)">
|
||||||
|
{{ it.label }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-modal>
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stats {
|
.tiles {
|
||||||
margin-bottom: 16px;
|
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 {
|
.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 {
|
.pulse {
|
||||||
@@ -236,4 +379,11 @@ watch(showModal, (v) => {
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.tiles,
|
||||||
|
.site {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { editUser } from "admin/api"
|
import { editUser } from "admin/api"
|
||||||
import type { AdminUserForm } from "utils/types"
|
import type { AdminUserForm } from "utils/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户列表一行的操作(设计稿「后台用户与批量建号」UsList):最常用的「重置密码」常驻
|
* 用户列表一行的操作:重置密码、编辑、禁用、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||||
* (学生忘了密码是后台进得最多的一件事),其余收进 ⋯。原来四颗彩色按钮并排,
|
* 删除前确认,交过题的账号删不掉,提示改为禁用
|
||||||
* 1366 的屏上把「上次登录」挤成了半截
|
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
user: AdminUserForm
|
user: AdminUserForm
|
||||||
@@ -27,17 +25,7 @@ async function banUser() {
|
|||||||
emit("userBanned", props.user)
|
emit("userBanned", props.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuOptions = computed<DropdownOption[]>(() => [
|
function confirmDelete() {
|
||||||
{ label: "编辑", key: "edit" },
|
|
||||||
{ label: props.user.isDisabled ? "解除禁用" : "禁用", key: "ban" },
|
|
||||||
{ type: "divider", key: "divider" },
|
|
||||||
{ label: "删除", key: "delete" },
|
|
||||||
])
|
|
||||||
|
|
||||||
function onMenu(key: string) {
|
|
||||||
if (key === "edit") emit("openEditModal", props.user)
|
|
||||||
else if (key === "ban") banUser()
|
|
||||||
else if (key === "delete") {
|
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: `删除 ${props.user.username}?`,
|
title: `删除 ${props.user.username}?`,
|
||||||
content: "删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。",
|
content: "删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。",
|
||||||
@@ -45,16 +33,15 @@ function onMenu(key: string) {
|
|||||||
negativeText: "不删",
|
negativeText: "不删",
|
||||||
onPositiveClick: () => emit("deleteUser", [props.user.id]),
|
onPositiveClick: () => emit("deleteUser", [props.user.id]),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex :size="4" justify="end" :wrap="false">
|
<n-flex :size="6" :wrap="false">
|
||||||
<n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
|
<n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
|
||||||
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
|
<n-button size="small" @click="emit('openEditModal', props.user)">编辑</n-button>
|
||||||
<n-button size="small" quaternary aria-label="更多">
|
<n-button size="small" @click="banUser">
|
||||||
<template #icon><Icon icon="ph:dots-three-bold" /></template>
|
{{ props.user.isDisabled ? "解除禁用" : "禁用" }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-dropdown>
|
<n-button size="small" quaternary type="error" @click="confirmDelete">删除</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
@@ -96,7 +96,9 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
|||||||
{
|
{
|
||||||
title: "用户名",
|
title: "用户名",
|
||||||
key: "username",
|
key: "username",
|
||||||
minWidth: 200,
|
// 定宽:用户名一般就 ks + 班号 + 两三个字,原来 minWidth 让它把整行剩下的空都吃掉了(用户嫌空太多)。
|
||||||
|
// 剩下的宽度给最后的操作列,操作直接摆出来
|
||||||
|
width: 230,
|
||||||
render: (row) => h(UserNameCell, { user: row }),
|
render: (row) => h(UserNameCell, { user: row }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -133,7 +135,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
|||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
title: "",
|
title: "",
|
||||||
width: 130,
|
minWidth: 300,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
user: row,
|
user: row,
|
||||||
|
|||||||
@@ -369,6 +369,28 @@ export const dashboardInfoSchema = z.object({
|
|||||||
recentContestCount: z.number().int(),
|
recentContestCount: z.number().int(),
|
||||||
todaySubmissionCount: z.number().int(),
|
todaySubmissionCount: z.number().int(),
|
||||||
judgeServerCount: z.number().int(),
|
judgeServerCount: z.number().int(),
|
||||||
|
/** 今天判出系统错误的提交数:判题机出毛病时它先涨,比学生喊「交了没反应」早 */
|
||||||
|
todaySystemErrorCount: z.number().int(),
|
||||||
|
/** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */
|
||||||
|
onlineCount: z.number().int(),
|
||||||
|
/** 正在上课的班(最近两小时同班在交题,和课堂看板猜班同一个办法);没有为 null */
|
||||||
|
activeClass: z.string().nullable(),
|
||||||
|
/** 全站的数(看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */
|
||||||
|
site: z.object({
|
||||||
|
students: z.number().int(),
|
||||||
|
teachers: z.number().int(),
|
||||||
|
problems: z.number().int(),
|
||||||
|
visibleProblems: z.number().int(),
|
||||||
|
submissions: z.number().int(),
|
||||||
|
termSubmissions: z.number().int(),
|
||||||
|
contests: z.number().int(),
|
||||||
|
problemSets: z.number().int(),
|
||||||
|
tutorials: z.number().int(),
|
||||||
|
/** 这周(周一起)交过题的人数 */
|
||||||
|
weekActive: z.number().int(),
|
||||||
|
}),
|
||||||
|
/** 最近 30 天每天交了几次(东八区日历日,含今天,没人交的那天是 0) */
|
||||||
|
daily: z.array(z.object({ day: z.string(), count: z.number().int() })),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const uploadImageResponseSchema = z.object({
|
export const uploadImageResponseSchema = z.object({
|
||||||
|
|||||||
Reference in new issue
Block a user