Compare commits

...
8 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 32f7bcaf8b feat(后台比赛): 比赛列表一行看全——开始时间和多长、几道题、几人参加、学生看得到;期中期末挂标,操作只剩「编辑」「成绩」和 ⋯
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:07:24 +08:00
xuyueandClaude Opus 5.5 591c7bcab9 feat(后台比赛): 比赛列表带上每场几道题、几人参加,按开始时间排,复制出来的旧比赛不再跑到最前面
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:07:24 +08:00
xuyueandClaude Opus 5.5 c59e31eeb2 feat(后台标签): 标签页改成知识点、主题左右两栏,一眼看出每个标签挂了几道题;改名、换分类、删除收进 ⋯,没归类的单独放最上面
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:04:06 +08:00
xuyueandClaude Opus 5.5 5561e0bc3a feat(后台用户): 用户列表按班级看、「重置密码」常驻;批量建号粘进名单当场列出会建成的账号,重名和已有的先点出来,建好能下载名单、打印账号条
用户列表去掉 ID、真名、占位邮箱和四颗彩色按钮。下载的名单开头带 BOM,Excel 打开中文不再乱码。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:03 +08:00
xuyueandClaude Opus 5.5 ba823ecb7d feat(后台用户): 用户列表能按班级筛、按用户名排、看出几人从没登录;批量建号前能查重名,新班级建好自动加进班级列表
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:03 +08:00
xuyueandClaude Opus 5.5 48738f74d9 fix(题目列表): 老师账号看题目列表也有「状态」列了,自己做对的题打勾
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:46:34 +08:00
xuyueandClaude Opus 5.5 97049d562b feat(后台题目): 题目列表重排——1366 屏上不用横滚就看得到「学生看得到」开关;一行只剩「编辑」和 ⋯,有人交过的题删除直接灰掉;勾几道一次放出、藏起、加减知识点
去掉内部 ID、难度、看不懂的「功能」图标和反馈表情几列;题目名点了就是编辑。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:39 +08:00
xuyueandClaude Opus 5.5 952edbf86f feat(后台题目): 题目列表带上做对 / 做过的人数和「学生看得到 / 藏着的」各几道,能一次放出或藏起好几道题;比赛题目也能按题号搜
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:39 +08:00
19 changed files with 1427 additions and 492 deletions

No files matched your search

+55 -4
View File
@@ -1,6 +1,9 @@
import {
adminTypeSchema,
checkUsernamesRequestSchema,
deleteUsersRequestSchema,
NO_CLASS,
type CheckUsernamesResponse,
importUsersRequestSchema,
STUDENT_ROLES,
updateUserRequestSchema,
@@ -14,7 +17,7 @@ import {
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { z } from "zod"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { hashPassword } from "../../auth/password"
@@ -24,6 +27,8 @@ import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { classPrefixCondition, queryInteger, sampleUser } from "../helpers"
import { publishConfigUpdate } from "../../events"
import { getWebsiteOptions } from "../../services/options"
export const adminAccountRoutes = new Hono<AppEnv>()
@@ -186,6 +191,13 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
)!,
)
}
// 班级:和全站班级下拉同一套值,NO_CLASS 是「没填班级」
const className = c.req.query("className")?.trim()
if (className) {
filters.push(
className === NO_CLASS ? isNull(schema.user.className) : eq(schema.user.className, className),
)
}
const where = filters.length ? and(...filters) : undefined
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
const online = await onlineUserIds()
@@ -205,11 +217,18 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
]
: orderBy === "-lastLogin"
? [sql`${schema.user.lastLogin} desc nulls last`]
: [desc(schema.user.createTime)]
: // 按用户名:选了一个班看名单时最顺手(和点名册一个顺序)
orderBy === "username"
? [asc(schema.user.username)]
: [desc(schema.user.createTime)]
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.select({
value: count(),
neverLoggedIn:
sql<number>`count(*) filter (where ${schema.user.lastLogin} is null)`.mapWith(Number),
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where),
@@ -225,9 +244,25 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
return success(c, {
results: rows.map((row) => serialize(row, online.has(row.user.id))),
total: totalRows[0]?.value ?? 0,
neverLoggedIn: totalRows[0]?.neverLoggedIn ?? 0,
} satisfies AdminUserList)
})
/**
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
* 比较时归一成小写,和导入、登录同一个口径
*/
adminAccountRoutes.post("/users/check", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, checkUsernamesRequestSchema)
if (!parsed.success) return parsed.response
const names = [...new Set(parsed.data.usernames.map((name) => name.toLowerCase()))]
const rows = await db
.select({ username: schema.user.username, createTime: schema.user.createTime })
.from(schema.user)
.where(inArray(sql`lower(${schema.user.username})`, names))
return success(c, { existing: rows } satisfies CheckUsernamesResponse)
})
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "user-not-found", "User does not exist")
@@ -451,7 +486,23 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
)
return users.length
})
return success(c, { imported: created }, 201)
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它,
// 原来要再去设置页手动加,经常忘
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
const options = await getWebsiteOptions()
const missing = classes.filter((name) => !options.class_list.includes(name!)) as string[]
if (missing.length) {
const next = [...options.class_list, ...missing].sort((a, b) =>
a.localeCompare(b, "zh-CN", { numeric: true }),
)
await db
.insert(schema.optionsSysoptions)
.values({ key: "class_list", value: next })
.onConflictDoUpdate({ target: schema.optionsSysoptions.key, set: { value: next } })
await publishConfigUpdate("classList", next)
}
return success(c, { imported: created, addedClasses: missing }, 201)
})
/**
+30 -3
View File
@@ -5,7 +5,7 @@ import {
type AdminContest,
type AdminContestList,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray } from "drizzle-orm"
import { and, count, countDistinct, desc, eq, ilike, inArray } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
@@ -93,12 +93,39 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.contest.createTime))
// 按开始时间:原来按创建时间,「复制一份」出来的旧比赛会排到最前面
.orderBy(desc(schema.contest.startTime), desc(schema.contest.id))
.limit(limit)
.offset(offset),
])
const ids = rows.map((row) => row.contest.id)
const [problemCounts, participantCounts] = ids.length
? await Promise.all([
db
.select({ contestId: schema.problem.contestId, value: count() })
.from(schema.problem)
.where(inArray(schema.problem.contestId, ids))
.groupBy(schema.problem.contestId),
db
.select({
contestId: schema.submission.contestId,
value: countDistinct(schema.submission.userId),
})
.from(schema.submission)
.where(inArray(schema.submission.contestId, ids))
.groupBy(schema.submission.contestId),
])
: [[], []]
const problemsOf = new Map(problemCounts.map((row) => [row.contestId, row.value]))
const peopleOf = new Map(participantCounts.map((row) => [row.contestId, row.value]))
return success(c, {
results: await Promise.all(rows.map(serialize)),
results: await Promise.all(
rows.map(async (row) => ({
...(await serialize(row)),
problemCount: problemsOf.get(row.contest.id) ?? 0,
participantCount: peopleOf.get(row.contest.id) ?? 0,
})),
),
total: totalRows[0]?.value ?? 0,
} satisfies AdminContestList)
})
+35 -3
View File
@@ -50,6 +50,7 @@ import { config } from "../../config"
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { getTopReactions } from "../../services/reaction"
import { problemUserCounts } from "../../services/problem-progress"
import { asRecord, queryInteger, sampleUser } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>()
@@ -447,8 +448,13 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
),
)
}
// 「全部 / 学生看得到 / 藏着的」:三个按钮上的数按除可见性以外的条件数,列表再按它筛
const beforeVisible = and(...filters)
const visibleQuery = c.req.query("visible")
if (visibleQuery === "1") filters.push(eq(schema.problem.visible, true))
else if (visibleQuery === "0") filters.push(eq(schema.problem.visible, false))
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
const [totalRow, rows, visibleRows] = await Promise.all([
db
.select({ value: count() })
.from(schema.problem)
@@ -467,13 +473,24 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
.orderBy(desc(schema.problem.createTime))
.limit(limit)
.offset(offset),
library
? []
: db
.select({ visible: schema.problem.visible, value: count() })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(beforeVisible)
.groupBy(schema.problem.visible),
])
// 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致
const problemIds = rows.map(({ problem }) => problem.id)
const [topReactions, tags] = await Promise.all([
const [topReactions, tags, userCounts] = await Promise.all([
getTopReactions(problemIds),
tagNamesFor(problemIds),
problemUserCounts(problemIds),
])
const visibleCount = visibleRows.find((row) => row.visible)?.value ?? 0
const hiddenCount = visibleRows.find((row) => !row.visible)?.value ?? 0
return success(c, {
results: rows.map(
({ problem, user: creator, realName }) =>
@@ -491,9 +508,14 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: topReactions.get(problem.id) ?? null,
solvedUsers: userCounts.get(problem.id)?.solved ?? 0,
triedUsers: userCounts.get(problem.id)?.tried ?? 0,
}) satisfies AdminProblemListItem,
),
total: totalRow[0]?.value ?? 0,
counts: library
? undefined
: { all: visibleCount + hiddenCount, visible: visibleCount, hidden: hiddenCount },
} satisfies AdminProblemList)
})
@@ -637,7 +659,15 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.problem.contestId, contestId)]
const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.problem.title, `%${keyword}%`))
// 和公开题列表一样,题号也能搜(搜索框写的是「题号或题目名」)
if (keyword) {
filters.push(
or(
ilike(schema.problem.title, `%${keyword}%`),
ilike(schema.problem.displayId, `%${keyword}%`),
)!,
)
}
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problem).where(where),
@@ -673,6 +703,8 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
solvedUsers: 0,
triedUsers: 0,
}) satisfies AdminProblemListItem,
),
total: totalRow[0]?.value ?? 0,
+23
View File
@@ -1,6 +1,7 @@
import {
parseFlowDoc,
batchProblemTagRequestSchema,
batchProblemVisibleRequestSchema,
generateFlowchartRequestSchema,
renameTagRequestSchema,
setTagCategoryRequestSchema,
@@ -253,6 +254,28 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
return success(c, { visible: !problem.visible })
})
/**
* 勾了几道,一次让学生看得到 / 藏起来(后台题目列表「已选 N 道」那一条)。
* 和批量加标签同一个范围:只认公开题,不是全部题目管理员就只认自己出的
*/
adminTagRoutes.post("/problems/batch-visible", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, batchProblemVisibleRequestSchema)
if (!parsed.success) return parsed.response
const user = c.get("user")!
const filters = [
inArray(schema.problem.id, parsed.data.problemIds),
isNull(schema.problem.contestId),
]
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id))
const updated = await db
.update(schema.problem)
.set({ visible: parsed.data.visible })
.where(and(...filters))
.returning({ id: schema.problem.id })
if (updated.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目")
return success(c, { problemCount: updated.length })
})
// ---------------------------------------------------------------- Python → 流程图
/**
+38 -3
View File
@@ -51,6 +51,7 @@ import type {
GenerateTestInputsRequest,
GenerateTestInputsResponse,
TestCaseFiles,
CheckUsernamesResponse,
} from "@oj2/contract"
export function getBaseInfo() {
@@ -72,17 +73,36 @@ export async function getProblemList(
tagId?: number,
/** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */
kind?: "sql" | "code" | "",
/** 1 = 学生看得到的,0 = 藏着的;不传是全部(比赛题列表不认) */
visible?: "1" | "0" | "",
) {
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
const res = await api.get<AdminProblemList>(endpoint, {
params: { offset, limit, keyword, author, tagId, kind: kind || undefined },
params: {
offset,
limit,
keyword,
author,
tagId,
kind: kind || undefined,
visible: visible || undefined,
},
})
return {
results: res.results.map(toAdminProblemRow),
total: res.total,
counts: res.counts ?? null,
}
}
/** 勾了几道,一次让学生看得到 / 藏起来 */
export function batchSetProblemVisible(problemIds: number[], visible: boolean) {
return api.post<{ problemCount: number }>("admin/problems/batch-visible", {
problemIds,
visible,
})
}
// 比赛「从题库中选择」用:列所有可见的公开题,不只是自己创建的
export async function getLibraryProblemList(offset = 0, limit = 10, keyword: string) {
const res = await api.get<AdminProblemList>("admin/problems", {
@@ -163,7 +183,15 @@ export function getAdminUserRank(offset: number, limit: number, keyword: string)
}
// 用户列表
export function getUserList(offset = 0, limit = 10, type = "", keyword: string, orderBy = "") {
export function getUserList(
offset = 0,
limit = 10,
type = "",
keyword: string,
orderBy = "",
/** 班级,NO_CLASS 是「没填班级」 */
className = "",
) {
return api.get<AdminUserList>("admin/users", {
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
// "-online" 是新增的,原样透传
@@ -173,10 +201,16 @@ export function getUserList(offset = 0, limit = 10, type = "", keyword: string,
keyword,
type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
className: className || undefined,
},
})
}
/** 批量建号之前查重:这些用户名哪些已经有了 */
export function checkUsernames(usernames: string[]) {
return api.post<CheckUsernamesResponse>("admin/users/check", { usernames })
}
// 编辑用户
export function editUser(user: AdminUserForm) {
return api.put<AdminUser>(`admin/users/${user.id}`, {
@@ -197,8 +231,9 @@ export async function resetPassword(userID: number) {
}
// 导入用户
/** 批量建号。addedClasses:这批里的新班级,后端已经顺手加进了班级列表 */
export function importUsers(users: string[][]) {
return api.post("admin/users", { users })
return api.post<{ imported: number; addedClasses: string[] }>("admin/users", { users })
}
// 批量删除用户
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import type { AdminContest } from "utils/types"
import { cloneContest } from "../../api"
@@ -21,6 +22,16 @@ function goClass() {
window.open(`/contest/${props.contest.id}/class`, "_blank")
}
const menuOptions: DropdownOption[] = [
{ label: "复制一份", key: "clone" },
{ 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() {
try {
const res = await cloneContest(props.contest.id)
@@ -35,12 +46,16 @@ async function clone() {
}
</script>
<template>
<n-flex>
<n-button size="small" type="primary" secondary @click="goEdit"> 编辑 </n-button>
<n-button size="small" type="info" secondary @click="goClass">
<n-flex :size="4" justify="end" :wrap="false">
<n-button size="small" @click="goEdit">编辑</n-button>
<n-button size="small" @click="goClass">
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
</n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button>
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
<n-button size="small" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</n-flex>
</template>
<style scoped></style>
+100 -43
View File
@@ -1,16 +1,22 @@
<script setup lang="ts">
import type { AdminContestListItem } from "@oj2/contract"
import PageHeader from "admin/components/PageHeader.vue"
import { NSwitch, NTag } from "naive-ui"
import { NSwitch, useThemeVars } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue"
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions"
import type { AdminContest } from "utils/types"
import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue"
const contests = ref<AdminContest[]>([])
/**
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
* 要密码的带锁)、开始时间和多长、状态、几道题、几人参加、出卷人、学生看得到;
* 操作「编辑」「成绩 / 全班情况」+ ⋯。按开始时间排 —— 原来按创建时间,复制出来的旧比赛
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
*/
const theme = useThemeVars()
const contests = ref<AdminContestListItem[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
@@ -18,57 +24,79 @@ const query = reactive({
keyword: "",
})
function toggleVisible(contest: AdminContest) {
function toggleVisible(contest: AdminContestListItem) {
contest.visible = !contest.visible
editContest(contest)
}
const columns: DataTableColumn<AdminContest>[] = [
{ title: "ID", key: "id", width: 60 },
/** 多长:一节课的写分钟 / 小时,挂好几天的练习写天 */
function duration(row: AdminContestListItem) {
const minutes = Math.round((Date.parse(row.endTime) - Date.parse(row.startTime)) / 60_000)
if (minutes < 60) return `${minutes} 分钟`
if (minutes < 48 * 60) {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
}
return `${Math.round(minutes / 1440)} 天`
}
const EXAM_TAGS = ["期中", "期末"]
const columns: DataTableColumn<AdminContestListItem>[] = [
{
title: "比赛",
key: "title",
minWidth: 200,
render: (row) => h(ContestTitle, { contest: row }),
minWidth: 220,
render: (row) =>
h("span", { class: "title-cell" }, [
h(ContestTitle, { contest: row }),
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
]),
},
{
title: "标签",
key: "tag",
width: 100,
},
{
title: "类型",
key: "contest_type",
width: 100,
render: (row) => h(ContestAccessTag, { contest: row, size: "small" }),
title: "开始 · 多长",
key: "start_time",
width: 220,
render: (row) =>
h("span", { class: "num" }, [
parseTime(row.startTime, "YYYY-MM-DD HH:mm"),
h("span", { class: "muted" }, ` · ${duration(row)}`),
]),
},
{
title: "状态",
key: "status",
width: 100,
width: 80,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"], size: "small" },
() => CONTEST_STATUS[row.status]["name"],
"span",
{ class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
row.status === "-1"
? "结束了"
: row.status === "0"
? "进行中"
: CONTEST_STATUS[row.status].name,
),
},
{ title: "题", key: "problemCount", width: 56 },
{
title: "创建者",
title: "参加",
key: "participantCount",
width: 64,
render: (row) => (row.participantCount ? String(row.participantCount) : "—"),
},
{
title: "出卷",
key: "created_by",
width: 120,
width: 96,
ellipsis: { tooltip: true },
render: (row) => row.createdBy.username,
},
{
title: "创建时间",
key: "create_time",
width: 160,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm"),
},
{
title: "可见",
title: "学生看得到",
key: "visible",
width: 100,
width: 96,
render: (row) =>
h(NSwitch, {
value: row.visible,
@@ -78,10 +106,9 @@ const columns: DataTableColumn<AdminContest>[] = [
}),
},
{
title: "选项",
title: "",
key: "actions",
fixed: "right",
width: 300,
width: 190,
render: (row) => h(Actions, { contest: row }),
},
]
@@ -101,21 +128,51 @@ watchDebounced(() => query.keyword, listContests, {
</script>
<template>
<PageHeader title="比赛列表">
<PageHeader title="比赛">
<template #actions>
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })">
新建比赛
</n-button>
</template>
<template #filters>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
style="width: 220px"
/>
<n-input v-model:value="query.keyword" placeholder="比赛名" clearable style="width: 220px" />
</template>
</PageHeader>
<n-data-table :columns="columns" :data="contests" />
<n-data-table class="contest-table" :columns="columns" :data="contests" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
<style scoped>
.contest-table :deep(.title-cell) {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.contest-table :deep(.exam) {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
font-weight: 400;
color: v-bind("theme.errorColor");
background: v-bind("theme.actionColor");
}
.contest-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contest-table :deep(.muted) {
color: v-bind("theme.textColor3");
}
.contest-table :deep(.live) {
color: v-bind("theme.successColor");
font-weight: 600;
}
</style>
@@ -1,13 +1,22 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { errorCode, errorMessage } from "utils/api"
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
import download from "utils/download"
/**
* 题目列表一行的操作(设计稿「后台题目列表」PlA):常用的「编辑」常驻,其余收进 ⋯ ——
* 原来是四颗彩色按钮(编辑 / 查看 / 删除 / 下载)并排,1366 宽的屏上把「可见」开关挤出了屏幕。
* 有人交过的题删不掉(服务端也拦),「删除」直接灰掉并写明为什么,不用点了才知道
*/
interface Props {
problemID: number
problemDisplayID: string
/** 做过这道题的人数,> 0 就删不掉 */
triedUsers?: number
}
const props = defineProps<Props>()
const dialog = useDialog()
const emit = defineEmits(["updated"])
const route = useRoute()
@@ -60,6 +69,31 @@ function goCheck() {
window.open(data.href, "_blank")
}
const menuOptions = computed<DropdownOption[]>(() => [
{ 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({
title: `删除 ${props.problemDisplayID}?`,
content: "删掉就找不回来了。",
positiveText: "删除",
negativeText: "不删",
onPositiveClick: handleDeleteProblem,
})
}
}
function openMakePublicModal() {
newDisplayID.value = ""
showMakePublicModal.value = true
@@ -88,29 +122,13 @@ async function handleMakePublic() {
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit"> 编辑 </n-button>
<n-button size="small" secondary type="info" @click="goCheck"> 查看 </n-button>
<n-tooltip v-if="isContestProblem">
<template #trigger>
<n-button size="small" secondary type="warning" @click="openMakePublicModal">
公开
</n-button>
</template>
将此竞赛题目转为公开题目
</n-tooltip>
<n-popconfirm @positive-click="handleDeleteProblem">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这道题目吗?相关的提交也会被相应删除哦 😯
</n-popconfirm>
<n-tooltip>
<template #trigger>
<n-button size="small" secondary @click="downloads">下载</n-button>
</template>
下载测试用例
</n-tooltip>
<n-flex :size="4" justify="end" :wrap="false">
<n-button size="small" @click="goEdit">编辑</n-button>
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
<n-button size="small" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</n-flex>
<n-modal
@@ -26,29 +26,28 @@ const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemRow>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{ title: "题号", key: "_id", width: 90 },
{
title: "标题",
title: "题目",
key: "title",
minWidth: 200,
render: (row) =>
h(NButton, { text: true, type: "primary", onClick: () => goEdit(row) }, () => row.title),
render: (row) => h(NButton, { text: true, onClick: () => goEdit(row) }, () => row.title),
},
{
title: "可见",
title: "学生看得到",
key: "visible",
width: 80,
width: 100,
render: (row) =>
h(NTag, { size: "small", type: row.visible ? "success" : "default" }, () =>
row.visible ? "公开" : "隐藏",
h(NTag, { size: "small", bordered: false, type: row.visible ? "success" : "default" }, () =>
row.visible ? "看得到" : "藏着",
),
},
{
title: "选项",
title: "",
key: "actions",
width: 110,
width: 90,
render: (row) =>
h(NButton, { size: "small", type: "error", onClick: () => removeTag(row) }, () => "移除标签"),
h(NButton, { size: "small", quaternary: true, onClick: () => removeTag(row) }, () => "摘掉"),
},
]
@@ -119,7 +118,7 @@ watchDebounced(
<n-input
v-model:value="keyword"
style="width: 220px"
placeholder="输入标题关键字"
placeholder="题号或题目名"
clearable
/>
</n-flex>
+214 -96
View File
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
import { Icon } from "@iconify/vue"
import { NSwitch, useThemeVars } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions"
import { parseTime } from "utils/functions"
import type { AdminProblemRow } from "utils/types"
import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api"
import { errorMessage } from "utils/api"
import { batchSetProblemVisible, getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue"
import AddFromLibraryModal from "./components/AddFromLibraryModal.vue"
import { useRouteQuery } from "@vueuse/router"
@@ -17,6 +16,14 @@ import CreateProblemMenu from "./components/CreateProblemMenu.vue"
import PageHeader from "admin/components/PageHeader.vue"
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
/**
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
* 知识点、做对 / 做过的人数、出题人、创建日期、「学生看得到」开关,最右「编辑」+ ⋯。
*
* 原来 1366 宽的屏上「可见」开关和创建时间要横滚才看得到,出题人被截成三个字;
* 内部 ID 和显示编号两列、难度、看不懂的「功能」图标列、反馈表情列都拿掉了。
* 比赛题目列表(contestID)也用这一页,没有勾选、人数和「全部 / 看得到 / 藏着的」
*/
interface Props {
contestID?: string
}
@@ -24,6 +31,7 @@ interface Props {
const props = defineProps<Props>()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const title = computed(
() =>
@@ -75,6 +83,8 @@ interface ProblemQuery {
keyword: string
author: string
kind: "" | "code" | "sql"
/** 1 = 学生看得到的,0 = 藏着的 */
visible: "" | "1" | "0"
}
// 使用分页 composable
@@ -82,95 +92,88 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value,
author: useRouteQuery("author", "").value,
kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value,
visible: useRouteQuery<ProblemQuery["visible"]>("visible", "").value,
})
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
const visibleTabs = computed(() => [
{ value: "" as const, label: "全部", count: counts.value?.all },
{ value: "1" as const, label: "学生看得到", count: counts.value?.visible },
{ value: "0" as const, label: "藏着的", count: counts.value?.hidden },
])
/** 知识点只摆前两个,多的写 +N,鼠标放上去看全部(P073 这类题挂了六个) */
function renderTags(tags: string[]) {
if (!tags.length) return h("span", { class: "muted" }, "—")
return h("span", { class: "tags", title: tags.join("、") }, [
...tags.slice(0, 2).map((tag) => h("span", { class: "tag", key: tag }, tag)),
tags.length > 2 ? h("span", { class: "muted more" }, `+${tags.length - 2}`) : null,
])
}
function renderTitle(row: AdminProblemRow) {
const kinds = [
row.isSql ? h(ProblemTypeTag, { kind: "sql" }) : null,
row.allowFlowchart ? h(ProblemTypeTag, { kind: "flowchart" }) : null,
row.hasAstRules ? h(ProblemTypeTag, { kind: "ast" }) : null,
]
return h("span", { class: "title-cell" }, [
h(
"a",
{
class: "title-link",
href: router.resolve(editRoute(row.id)).href,
onClick: (event: MouseEvent) => {
if (event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
router.push(editRoute(row.id))
},
},
row.title,
),
...kinds,
])
}
function editRoute(problemID: number) {
return {
name: isContestProblemList.value ? "admin contest problem edit" : "admin problem edit",
params: isContestProblemList.value ? { contestID: props.contestID, problemID } : { problemID },
}
}
const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 },
{
title: "标题",
key: "title",
minWidth: 200,
title: "题号",
key: "_id",
width: 84,
render: (row) => h("span", { class: "did", title: `内部编号 ${row.id}` }, row._id),
},
{ title: "题目", key: "title", minWidth: 220, ellipsis: false, render: renderTitle },
{ title: "知识点", key: "tags", width: 190, render: (row) => renderTags(row.tags) },
{
title: "做对 / 做过",
key: "users",
width: 100,
render: (row) =>
row.isSql
? h(NFlex, { size: 6, align: "center", wrap: false }, () => [
h("span", row.title),
h(ProblemTypeTag, { kind: "sql" }),
row.triedUsers
? h("span", { class: "num" }, [
String(row.solvedUsers),
h("span", { class: "muted" }, ` / ${row.triedUsers}`),
])
: row.title,
: h("span", { class: "muted" }, "没人做过"),
},
{ title: "出题人", key: "username", width: 100, ellipsis: { tooltip: true } },
{
title: "难度",
key: "difficulty",
width: 80,
render: (row) =>
h(
NTag,
{ type: getTagColor(row.difficulty), size: "small" },
() => DIFFICULTY[row.difficulty],
),
},
{
title: "标签",
key: "tags",
minWidth: 120,
render: (row) =>
h(NFlex, { size: 4 }, () => row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t))),
},
{
title: "功能",
key: "features",
width: 80,
render: (row) =>
h(NFlex, { size: 4, align: "center" }, () => [
row.allowFlowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-drawio",
title: "绘图",
})
: row.showFlowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-graphql",
title: "流程图",
})
: null,
row.hasAstRules
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-light-todo",
title: "AST",
})
: null,
]),
},
{
title: "反馈",
key: "topReaction",
width: 60,
render: (row) => {
const top = row.topReaction
if (!top) return null
const reaction = REACTIONS.find((it) => it.key === top.type)
if (!reaction) return null
return h(NTooltip, null, {
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
default: () => `${reaction.label} ${top.count} 人`,
})
},
},
{ title: "出题人", key: "username", width: 120 },
{
title: "创建时间",
title: "创建",
key: "create_time",
width: 200,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
width: 96,
render: (row) => h("span", { class: "num muted" }, parseTime(row.createTime, "YYYY-MM-DD")),
},
{
title: "可见",
title: "学生看得到",
key: "visible",
minWidth: 100,
width: 96,
render: (row) =>
h(NSwitch, {
value: row.visible,
@@ -180,23 +183,22 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
}),
},
{
title: "选项",
title: "",
key: "actions",
fixed: "right",
width: 300,
width: 104,
render: (row) =>
h(Actions, {
problemID: row.id,
problemDisplayID: row._id,
triedUsers: row.triedUsers,
onUpdated: listProblems,
}),
},
]
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() =>
isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction")
? baseColumns.filter((it) => !("key" in it) || it.key !== "users")
: [{ type: "selection" }, ...baseColumns],
)
@@ -211,9 +213,24 @@ async function listProblems() {
props.contestID,
undefined,
query.kind,
props.contestID ? "" : query.visible,
)
total.value = res.total
problems.value = res.results
counts.value = res.counts
}
/** 勾了几道,一次让学生看得到 / 藏起来(lwp0201 一批 P0xx 题出完一起放出来就是这么用) */
const message = useMessage()
async function batchVisible(visible: boolean) {
try {
const { problemCount } = await batchSetProblemVisible(selectedProblemIds.value, visible)
message.success(`${problemCount} 道题${visible ? "学生看得到了" : "藏起来了"}`)
selectedRowKeys.value = []
listProblems()
} catch (error) {
message.error(errorMessage(error))
}
}
async function toggleVisible(problemID: number) {
@@ -224,6 +241,15 @@ async function toggleVisible(problemID: number) {
}
return it
})
// 三个按钮上的数跟着变;停在「藏着的」时这道题也该从列表里走掉,但当场消失会让人以为没点上,下次刷新再说
if (counts.value) {
const now = problems.value.find((it) => it.id === problemID)?.visible
counts.value = {
...counts.value,
visible: counts.value.visible + (now ? 1 : -1),
hidden: counts.value.hidden + (now ? -1 : 1),
}
}
}
const kindOptions = [
@@ -254,7 +280,7 @@ watchDebounced(() => query.keyword, listProblems, {
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
watch(() => [query.page, query.limit, query.author, query.kind, query.visible], listProblems)
</script>
<template>
@@ -264,12 +290,6 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
>
<template #actions>
<template v-if="!isContestProblemList">
<template v-if="selectedProblemIds.length">
<n-button @click="openBatchTag('remove')">移除标签</n-button>
<n-button secondary type="primary" @click="openBatchTag('add')">
添加标签({{ selectedProblemIds.length }})
</n-button>
</template>
<CreateProblemMenu label="新建题目" primary @select="createProblem" />
</template>
<template v-else>
@@ -280,7 +300,7 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
<template #filters>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
placeholder="题号或题目名"
clearable
style="width: 220px"
@clear="clearQuery"
@@ -296,13 +316,35 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
<n-text depth="3">出题人</n-text>
<AuthorSelect v-model:value="query.author" all />
</n-flex>
<n-button-group v-if="!props.contestID">
<n-button
v-for="tab in visibleTabs"
:key="tab.value"
:type="query.visible === tab.value ? 'primary' : 'default'"
:secondary="query.visible === tab.value"
@click="query.visible = tab.value"
>
{{ tab.label }}
<span v-if="tab.count !== undefined" class="tab-count">{{ tab.count }}</span>
</n-button>
</n-button-group>
</template>
</PageHeader>
<div v-if="selectedProblemIds.length" class="batch-bar">
<b>已选 {{ selectedProblemIds.length }} 道</b>
<n-button size="small" @click="batchVisible(true)">让学生看得到</n-button>
<n-button size="small" @click="batchVisible(false)">藏起来</n-button>
<n-button size="small" @click="openBatchTag('add')">加知识点</n-button>
<n-button size="small" @click="openBatchTag('remove')">去掉知识点</n-button>
<div class="spacer"></div>
<n-button size="small" quaternary @click="selectedRowKeys = []">取消选择</n-button>
</div>
<n-data-table
striped
class="problem-table"
:columns="columns"
:data="problems"
:row-key="rowKey"
:checked-row-keys="selectedRowKeys"
@update:checked-row-keys="chooseProblems"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
@@ -319,3 +361,79 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
@done="onBatchTagDone"
/>
</template>
<style scoped>
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid v-bind("theme.successColorSuppl");
background: v-bind("theme.hoverColor");
white-space: nowrap;
}
.spacer {
flex: 1 1 auto;
}
.tab-count {
margin-left: 6px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.problem-table :deep(.did) {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.problem-table :deep(.title-cell) {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.problem-table :deep(.title-link) {
color: inherit;
font-weight: 500;
text-decoration: none;
}
.problem-table :deep(.title-link:hover) {
color: v-bind("theme.primaryColor");
text-decoration: underline;
}
.problem-table :deep(.tags) {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.problem-table :deep(.tag) {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 7px;
border-radius: 3px;
background: v-bind("theme.actionColor");
font-size: 12px;
}
.problem-table :deep(.muted) {
color: v-bind("theme.textColor3");
}
.problem-table :deep(.more) {
font-size: 12px;
}
.problem-table :deep(.num) {
font-variant-numeric: tabular-nums;
}
</style>
+218 -139
View File
@@ -1,36 +1,21 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex, NInput, NRadioButton, NRadioGroup, NTag } from "naive-ui"
import { useThemeVars } from "naive-ui"
import type { AdminTag, TagCategory } from "utils/types"
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
import TagProblemsModal from "./components/TagProblemsModal.vue"
const message = useMessage()
const dialog = useDialog()
const theme = useThemeVars()
const tags = ref<AdminTag[]>([])
const keyword = ref("")
type CategoryFilter = "" | "pending" | TagCategory
const categoryFilter = ref<CategoryFilter>("")
// 搜索在后端做(getTagAdminList 带 keyword),这里只是一份引用,两栏和待归类都从它分
const visibleTags = computed(() => tags.value)
// 出题时新建的标签默认算「知识点」、还没人确认过,数出来挂在页头提醒
const pendingCount = computed(() => tags.value.filter((t) => !t.categoryConfirmed).length)
// 标签总共几十个,分类筛选在前端做,不必为它再加一个 query 参数
const visibleTags = computed(() => {
const filter = categoryFilter.value
if (!filter) return tags.value
if (filter === "pending") return tags.value.filter((t) => !t.categoryConfirmed)
return tags.value.filter((t) => t.category === filter)
})
const categoryOptions = computed(() => [
{ label: "全部分类", value: "" },
{ label: `待归类(${pendingCount.value})`, value: "pending" },
{ label: "知识点", value: "knowledge" },
{ label: "主题", value: "theme" },
])
const editingId = ref<number | null>(null)
const editingName = ref("")
@@ -42,113 +27,46 @@ function openTagProblems(tag: AdminTag) {
toggleTagProblems(true)
}
const columns: DataTableColumn<AdminTag>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "标签名",
key: "name",
minWidth: 200,
render: (row) =>
editingId.value === row.id
? h(NInput, {
value: editingName.value,
autofocus: true,
size: "small",
style: "max-width: 240px",
onUpdateValue: (v: string) => (editingName.value = v),
onKeyup: (e: KeyboardEvent) => {
if (e.key === "Enter") saveTag(row)
if (e.key === "Escape") cancelEdit()
},
})
: h(NFlex, { size: 8, align: "center" }, () => [
h(
NButton,
{
text: true,
type: "primary",
onClick: () => openTagProblems(row),
},
() => row.name,
),
row.categoryConfirmed
? null
: h(NTag, { size: "small", type: "warning", bordered: false }, () => "待归类"),
]),
},
{
// 前台题目列表按这一列把标签分成「知识点」「主题」两组
title: "分类",
key: "category",
width: 240,
render: (row) =>
h(NFlex, { size: 8, align: "center", wrap: false }, () => [
h(
NRadioGroup,
{
size: "small",
value: row.category,
onUpdateValue: (v: TagCategory) => changeCategory(row, v),
},
() => [
h(NRadioButton, { value: "knowledge" }, () => "知识点"),
h(NRadioButton, { value: "theme" }, () => "主题"),
],
),
// 新标签默认已经是「知识点」,点它不会触发 update,所以单给一个确认按钮
row.categoryConfirmed
? null
: h(
NButton,
{
size: "small",
type: "primary",
secondary: true,
onClick: () => changeCategory(row, row.category),
},
() => "确认",
),
]),
},
{
title: "题目数",
key: "problem_count",
width: 100,
render: (row) =>
h(NButton, { text: true, type: "primary", onClick: () => openTagProblems(row) }, () =>
String(row.problemCount),
),
},
{
title: "选项",
key: "actions",
width: 200,
render: (row) =>
h(NFlex, { size: 8 }, () =>
editingId.value === row.id
? [
h(
NButton,
{ size: "small", type: "primary", onClick: () => saveTag(row) },
() => "保存",
),
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
]
: [
h(NButton, { size: "small", onClick: () => startEdit(row) }, () => "重命名"),
h(
NButton,
{
size: "small",
type: "error",
onClick: () => confirmDelete(row),
},
() => "删除",
),
],
),
},
]
/**
* 两栏:知识点 / 主题(设计稿「后台标签」TagA)。原来是一张宽表,每行一组单选按钮切分类、
* 两颗按钮,ID 一列、页头一段说明。现在一行一个标签 + 挂了几道题(条的长短看出哪个用得多),
* 点名字看这个标签下的题,改名 / 换分类 / 删除收进 ⋯。还没归类的单独一条放最上面
*/
const columnsOf = computed(() => {
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
return [
{
key: "knowledge" as const,
title: "知识点",
tags: confirmed.filter((t) => t.category === "knowledge"),
},
{ key: "theme" as const, title: "主题", tags: confirmed.filter((t) => t.category === "theme") },
].map((col) => ({
...col,
tags: [...col.tags].sort((a, b) => b.problemCount - a.problemCount),
max: Math.max(1, ...col.tags.map((t) => t.problemCount)),
}))
})
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() {
const res = await getTagAdminList(keyword.value)
@@ -194,8 +112,10 @@ async function changeCategory(tag: AdminTag, category: TagCategory) {
function confirmDelete(tag: AdminTag) {
dialog.warning({
title: "删除标签",
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problemCount} 道题在使用它,删除后这些题目会失去该标签。`,
title: `删除「${tag.name}」?`,
content: tag.problemCount
? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。`
: "没有题目挂着它。",
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
@@ -212,17 +132,67 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</script>
<template>
<PageHeader title="标签管理">
<PageHeader title="标签">
<template #filters>
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
<n-select v-model:value="categoryFilter" :options="categoryOptions" style="width: 160px" />
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜标签" clearable />
</template>
</PageHeader>
<n-alert v-if="pendingCount > 0" type="warning" :bordered="false" class="pending-alert">
有 {{ pendingCount }} 个新标签还没归类(出题时新建的标签默认算「知识点」)。
前台题目列表按分类把标签分成「知识点」「主题」两组,选好分类或点「确认」即可。
</n-alert>
<n-data-table striped :columns="columns" :data="visibleTags" />
<div v-if="pendingTags.length" class="pending">
<b>还没归类</b>
<div v-for="tag in pendingTags" :key="tag.id" class="pending-item">
<n-button text @click="openTagProblems(tag)">{{ tag.name }}</n-button>
<span class="muted">{{ tag.problemCount }} 道</span>
<n-button size="small" @click="changeCategory(tag, 'knowledge')">算知识点</n-button>
<n-button size="small" @click="changeCategory(tag, 'theme')">算主题</n-button>
</div>
</div>
<div class="columns">
<section v-for="col in columnsOf" :key="col.key" class="column">
<div class="column-head">
<b>{{ col.title }}</b>
<span class="muted">{{ col.tags.length }} 个</span>
</div>
<div class="list">
<n-empty v-if="!col.tags.length" size="small" description="没有" class="empty" />
<div v-for="tag in col.tags" :key="tag.id" class="row">
<template v-if="editingId === tag.id">
<n-input
v-model:value="editingName"
size="small"
autofocus
class="rename"
@keyup.enter="saveTag(tag)"
@keyup.esc="cancelEdit"
/>
<n-button size="small" type="primary" @click="saveTag(tag)">保存</n-button>
<n-button size="small" @click="cancelEdit">取消</n-button>
</template>
<template v-else>
<button type="button" class="name" :title="tag.name" @click="openTagProblems(tag)">
{{ tag.name }}
</button>
<span class="bar"
><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i
></span>
<span class="count">{{ tag.problemCount }} 道</span>
<n-dropdown
trigger="click"
placement="bottom-end"
:options="menuOf(tag)"
@select="(key: string) => onMenu(tag, key)"
>
<n-button size="small" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
</div>
</div>
</section>
</div>
<TagProblemsModal
v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0"
@@ -232,7 +202,116 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</template>
<style scoped>
.pending-alert {
margin-bottom: 12px;
.pending {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 18px;
margin-bottom: 14px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(240, 160, 32, 0.45);
background: rgba(240, 160, 32, 0.08);
}
.pending-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.columns {
display: flex;
gap: 20px;
align-items: flex-start;
}
.column {
flex: 1 1 0;
min-width: 0;
}
.column-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
font-size: 16px;
}
.list {
border: 1px solid v-bind("theme.dividerColor");
border-radius: 8px;
padding: 2px 6px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 4px 0 8px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.row:last-child {
border-bottom: 0;
}
.name {
flex: 0 0 140px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 15px;
text-align: left;
color: v-bind("theme.textColor1");
cursor: pointer;
}
.name:hover {
color: v-bind("theme.primaryColor");
text-decoration: underline;
}
.bar {
flex: 1 1 auto;
height: 6px;
border-radius: 3px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.bar i {
display: block;
height: 6px;
border-radius: 3px;
background: v-bind("theme.successColorSuppl");
}
.count {
width: 52px;
text-align: right;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
}
.rename {
flex: 1 1 auto;
}
.muted {
font-size: 13px;
color: v-bind("theme.textColor3");
}
.empty {
margin: 16px 0;
}
</style>
+2
View File
@@ -16,5 +16,7 @@ export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow
allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart,
topReaction: result.topReaction,
solvedUsers: result.solvedUsers,
triedUsers: result.triedUsers,
}
}
+36 -21
View File
@@ -1,7 +1,13 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { editUser } from "admin/api"
import type { AdminUserForm } from "utils/types"
/**
* 用户列表一行的操作(设计稿「后台用户与批量建号」UsList):最常用的「重置密码」常驻
* (学生忘了密码是后台进得最多的一件事),其余收进 ⋯。原来四颗彩色按钮并排,
* 1366 的屏上把「上次登录」挤成了半截
*/
interface Props {
user: AdminUserForm
}
@@ -13,33 +19,42 @@ const emit = defineEmits<{
(e: "resetPassword", value: AdminUserForm): void
}>()
const dialog = useDialog()
async function banUser() {
props.user.isDisabled = !props.user.isDisabled
await editUser(props.user)
emit("userBanned", props.user)
}
const menuOptions = computed<DropdownOption[]>(() => [
{ 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({
title: `删除 ${props.user.username}?`,
content: "删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。",
positiveText: "删除",
negativeText: "不删",
onPositiveClick: () => emit("deleteUser", [props.user.id]),
})
}
}
</script>
<template>
<n-flex>
<n-button size="small" type="error" secondary @click="$emit('resetPassword', props.user)">
重置密码
</n-button>
<n-button size="small" type="primary" secondary @click="$emit('openEditModal', props.user)">
编辑
</n-button>
<n-button
size="small"
secondary
:type="props.user.isDisabled ? 'info' : 'error'"
@click="banUser"
>
{{ props.user.isDisabled ? "解封" : "封号" }}
</n-button>
<n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])">
<template #trigger>
<n-button size="small" secondary type="warning">删除</n-button>
</template>
确定删除这个用户吗?删除后无法恢复!
</n-popconfirm>
<n-flex :size="4" justify="end" :wrap="false">
<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" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</n-flex>
</template>
@@ -11,20 +11,27 @@ const props = defineProps<Props>()
const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER)
</script>
<template>
<n-flex align="center">
<n-tag v-if="props.user.isDisabled" type="error" size="small"> 封号中 </n-tag>
<n-tag v-if="hasRoleTag" :type="getUserRole(props.user.adminType).type" size="small">
<!-- 用户名在前(老师要认的是它),角色、禁用这些标签跟在后面 -->
<n-flex align="center" :size="6" :wrap="false">
<TextCopy>{{ props.user.username }}</TextCopy>
<n-tag v-if="props.user.isDisabled" type="error" size="small" :bordered="false"> 已禁用 </n-tag>
<n-tag
v-if="hasRoleTag"
:type="getUserRole(props.user.adminType).type"
size="small"
:bordered="false"
>
{{ getUserRole(props.user.adminType).label }}
</n-tag>
<n-tag
size="small"
v-if="
props.user.adminType === USER_TYPE.STUDENT_ADMIN ||
props.user.adminType === USER_TYPE.TEACHER_ADMIN
"
size="small"
:bordered="false"
>
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部" : "仅自己" }}
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部题目" : "自己的题" }}
</n-tag>
<TextCopy>{{ props.user.username }}</TextCopy>
</n-flex>
</template>
+422 -86
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { classLabel } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { errorMessage } from "utils/api"
import {
CLASS_NAME_MAX_DIGITS,
@@ -7,113 +9,447 @@ import {
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api"
import { parseTime } from "utils/functions"
import { checkUsernames, importUsers } from "../api"
/**
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
*
* 一年就开学那几天用(2026-09 七批共 440 个),所以要的是「粘进去就看到会建成什么」:
* 右边当场列出用户名和密码,同批重名、已经有的账号在建之前就点出来哪一行、怎么改。
* 建好之后给「下载名单」和「打印账号条」(一人一张,剪开发下去),原来是一点就自动下载
* 一个 CSV,Excel 打开中文还是乱码。新班级后端会顺手加进班级列表。
*/
const message = useMessage()
const prefix = ref(0)
const classNo = ref("")
const rawInput = ref("")
const [loading, toggleLoading] = useToggle()
const users = shallowRef<string[][]>([])
function generateUsers() {
if (!rawInput.value || !rawInput.value.trim()) {
message.info("请填写相关内容")
return false
const classError = computed(() => {
const value = classNo.value.trim()
if (!value) return ""
const n = Number(value)
if (!/^\d+$/.test(value) || n < CLASS_NAME_MIN_VALUE || n > CLASS_NAME_MAX_VALUE) {
return `班级号要是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`
}
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入,
// 这里先拦一道,省得填完一屏用户名才被打回来
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
return ""
})
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ""))
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
const names = computed(() =>
rawInput.value
.split("\n")
.map((line) => line.replace(/\s+/g, ""))
.filter(Boolean),
)
// 每个名字的密码记住,边改名单边看时不会每敲一个字全换一遍
const passwords = new Map<string, string>()
function passwordOf(name: string) {
let value = passwords.get(name)
if (!value) {
value = ""
for (let j = 0; j < 6; j++) value += "123456789".charAt(Math.floor(Math.random() * 9))
passwords.set(name, value)
}
let className = prefix.value ? `ks${prefix.value}` : ""
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重,而且库里同一个邮箱
// 出现两次的话,那两个账号在「编辑用户」里保存一次就撞 409,从此改不动。
// 原来只按「班级 + 批内序号」拼,同一个班分两批导入必然重号 —— 加一段每批随机的
// 后缀错开。这个地址不发给任何人,纯占位。
const batch = Math.random().toString(36).slice(2, 8)
rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n")
users.value = inputs.map((u, i) => {
const username = className + u
let password = ""
for (let j = 0; j < 6; j++) {
password += "123456789".charAt(Math.floor(Math.random() * 9))
}
const realName = u
const email = `${className || "user"}.${batch}.${i + 1}@example.com`
return [username, password, email, realName]
return value
}
const rows = computed(() =>
names.value.map((name) => ({
name,
username: prefix.value + name,
password: passwordOf(prefix.value + name),
})),
)
/** 同批重名:「第 4 行和第 2 行重名」 */
const duplicates = computed(() => {
const first = new Map<string, number>()
const result: { line: number; firstLine: number; name: string }[] = []
rows.value.forEach((row, i) => {
const key = row.username.toLowerCase()
const seen = first.get(key)
if (seen === undefined) first.set(key, i + 1)
else result.push({ line: i + 1, firstLine: seen, name: row.name })
})
return true
}
return result
})
async function uploadUsers() {
try {
toggleLoading(true)
await importUsers(users.value)
message.success("用户已上传成功")
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
// 真名本来就是老师粘进来的那一列,都不用回传
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n")
const hiddenElement = document.createElement("a")
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
hiddenElement.target = "_blank"
hiddenElement.download = prefix.value + ".csv"
hiddenElement.click()
hiddenElement.remove()
} catch (err) {
message.error("上传失败:" + errorMessage(err))
} finally {
toggleLoading(false)
}
}
// 已经有的账号:停手半秒再去查
const existing = ref<{ username: string; createTime: string | null }[]>([])
const checking = ref(false)
watchDebounced(
() => rows.value.map((row) => row.username).join("\n"),
async () => {
const usernames = [...new Set(rows.value.map((row) => row.username))]
if (!usernames.length || classError.value || usernames.length > 500) {
existing.value = []
return
}
checking.value = true
try {
existing.value = (await checkUsernames(usernames)).existing
} catch {
existing.value = []
} finally {
checking.value = false
}
},
{ debounce: 500, immediate: true },
)
const tooMany = computed(() => rows.value.length > 500)
const issueCount = computed(
() =>
(classError.value ? 1 : 0) +
duplicates.value.length +
existing.value.length +
(tooMany.value ? 1 : 0),
)
const canSubmit = computed(
() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value,
)
// ---------------------------------------------------------------- 建号
const saving = ref(false)
const done = ref<{ username: string; password: string }[] | null>(null)
const addedClasses = ref<string[]>([])
async function submit() {
const ok = generateUsers()
if (ok) {
uploadUsers()
if (!canSubmit.value) return
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重。每批一段随机后缀错开,
// 同一个班分两批导也不会撞。这个地址不发给任何人,纯占位
const batch = Math.random().toString(36).slice(2, 8)
const payload = rows.value.map((row, i) => [
row.username,
row.password,
`${prefix.value || "user"}.${batch}.${i + 1}@example.com`,
row.name,
])
saving.value = true
try {
const res = await importUsers(payload)
addedClasses.value = res.addedClasses ?? []
done.value = rows.value.map(({ username, password }) => ({ username, password }))
} catch (err) {
message.error("没建成:" + errorMessage(err))
} finally {
saving.value = false
}
}
function again() {
done.value = null
rawInput.value = ""
addedClasses.value = []
passwords.clear()
}
/** 下载名单:用户名、密码两列。开头带 BOM,Excel 打开中文才不乱码 */
function download() {
if (!done.value) return
const csv = "用户名,密码\n" + done.value.map((it) => `${it.username},${it.password}`).join("\n")
const link = document.createElement("a")
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
link.download = `${prefix.value || "账号"}.csv`
link.click()
URL.revokeObjectURL(link.href)
}
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
function printSlips() {
if (!done.value) return
const escape = (text: string) =>
text.replace(
/[&<>"]/g,
(ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]!,
)
const slips = done.value
.map(
(it) =>
`<div class="slip"><div class="u">${escape(it.username)}</div><div class="p">密码 ${escape(it.password)}</div></div>`,
)
.join("")
const win = window.open("", "_blank")
if (!win) {
message.warning("浏览器拦住了新窗口,允许弹出窗口后再点一次")
return
}
win.document.write(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escape(
classLabel(classNo.value.trim()) || "账号条",
)}</title><style>
body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:0}
.slip{border:1px dashed #999;padding:10px 12px;break-inside:avoid}
.u{font-size:16px;font-weight:600}.p{font-family:Consolas,monospace;font-size:16px;margin-top:4px}
</style></head><body><div class="grid">${slips}</div></body></html>`)
win.document.close()
win.focus()
win.print()
}
</script>
<template>
<PageHeader
title="批量导入用户"
description="每行一个名字,用户名为 ks + 班级号 + 名字,密码随机生成;导入后会下载账号清单"
:back="{ name: 'admin user list' }"
/>
<n-space>
<n-flex vertical>
<n-flex align="center">
<div style="width: 18px; font-size: 1.2rem">ks</div>
<n-input-number
style="width: 170px"
v-model:value="prefix"
clearable
:max="CLASS_NAME_MAX_VALUE"
:min="0"
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
<PageHeader title="批量建号" :back="{ name: 'admin user list' }" />
<!-- 建好了 -->
<div v-if="done" class="done">
<div class="done-bar">
<b>建好了 {{ done.length }} 个账号</b>
<n-text v-if="addedClasses.length" depth="3">
{{ addedClasses.map((name) => classLabel(name)).join("、") }}已加进班级列表
</n-text>
<div class="spacer"></div>
<n-button @click="download">下载名单(表格)</n-button>
<n-button type="primary" @click="printSlips">打印账号条</n-button>
<n-button @click="again">再建一批</n-button>
</div>
<div class="slips">
<div v-for="it in done" :key="it.username" class="slip">
<UserName :username="it.username" />
<div class="mono">密码 {{ it.password }}</div>
</div>
</div>
</div>
<div v-else class="layout">
<div class="left">
<label class="label" for="class-no">班级号</label>
<div class="class-row">
<span class="ks">ks</span>
<n-input
v-model:value="classNo"
:input-props="{ id: 'class-no', inputmode: 'numeric' }"
placeholder="如 2612"
style="width: 120px"
/>
</n-flex>
<span v-if="classNo.trim() && !classError" class="class-name">
{{ classLabel(classNo.trim()) }}
</span>
</div>
<label class="label" for="names">
名单 <n-text depth="3" class="hint">从表格里整列复制过来,一行一个</n-text>
</label>
<n-input
type="textarea"
class="inputArea"
placeholder="每行一个用户名"
v-model:value="rawInput"
type="textarea"
:input-props="{ id: 'names' }"
:rows="20"
placeholder="一行一个名字"
/>
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button>
</n-flex>
</n-space>
</div>
<div class="right">
<template v-if="rows.length">
<div class="head">
<b>会建这 {{ rows.length }} 个账号</b>
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round>
没有重名
</n-tag>
<n-tag v-if="issueCount" type="warning" :bordered="false" round>
有 {{ issueCount }} 处要先改
</n-tag>
</div>
<div v-if="issueCount" class="issues">
<div v-if="classError">{{ classError }}</div>
<div v-if="tooMany">一次最多 500 个,分两批建</div>
<div v-for="dup in duplicates" :key="dup.line">
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 ——
同班同名的,名字后面加个 1、2 区分
</div>
<div v-for="it in existing" :key="it.username">
{{ it.username }} 已经有这个账号了<template v-if="it.createTime"
>({{ parseTime(it.createTime, "YYYY-MM-DD") }} 建的)</template
>
—— 删掉这一行,或者换个名字
</div>
</div>
<div class="preview">
<table>
<thead>
<tr>
<th></th>
<th>用户名</th>
<th>密码</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td class="n">{{ i + 1 }}</td>
<td><UserName :username="row.username" /></td>
<td class="mono">{{ row.password }}</td>
</tr>
</tbody>
</table>
</div>
<div>
<n-button
type="primary"
size="large"
:disabled="!canSubmit"
:loading="saving || checking"
@click="submit"
>
建好这 {{ rows.length }} 个账号
</n-button>
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px">
先填班级号
</n-text>
</div>
</template>
<n-empty v-else description="左边粘进名单,这里就列出会建成的账号" style="margin-top: 80px" />
</div>
</div>
</template>
<style scoped>
.inputArea {
width: 200px;
height: 500px;
.layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
.left {
width: 330px;
flex: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.label {
font-size: 14px;
font-weight: 600;
}
.hint {
font-size: 13px;
font-weight: 400;
}
.class-row {
display: flex;
align-items: center;
gap: 8px;
}
.ks {
font-size: 18px;
opacity: 0.6;
}
.class-name {
font-size: 15px;
}
.right {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.issues {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid rgba(240, 160, 32, 0.45);
background: rgba(240, 160, 32, 0.08);
font-size: 14px;
}
.preview {
max-height: 520px;
overflow: auto;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 4px 12px;
}
.preview table {
border-collapse: collapse;
width: 100%;
font-size: 14px;
}
.preview th {
font-weight: 400;
font-size: 13px;
opacity: 0.6;
text-align: left;
padding: 8px;
}
.preview td {
height: 38px;
padding: 0 8px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
white-space: nowrap;
}
.preview .n {
width: 32px;
opacity: 0.5;
font-variant-numeric: tabular-nums;
}
.mono {
font-family: Consolas, Monaco, monospace;
font-variant-numeric: tabular-nums;
}
.done {
display: flex;
flex-direction: column;
gap: 16px;
}
.done-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid rgba(24, 160, 88, 0.35);
background: rgba(24, 160, 88, 0.06);
white-space: nowrap;
}
.done-bar b {
font-size: 16px;
}
.spacer {
flex: 1 1 auto;
}
.slips {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.slip {
border: 1px dashed rgba(128, 128, 128, 0.5);
border-radius: 4px;
padding: 8px 10px;
font-size: 14px;
line-height: 22px;
}
</style>
+118 -43
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api"
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import { DataTableRowKey, SelectOption, useThemeVars } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
@@ -12,14 +12,24 @@ import UserNameCell from "./components/UserNameCell.vue"
import PasswordCell from "./components/PasswordCell.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router"
import TextCopy from "shared/components/TextCopy.vue"
import { NO_CLASS } from "@oj2/contract"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { useConfigStore } from "shared/store/config"
/**
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
* 看一个班的名单、开学建号。所以加了班级筛选,一行只摆 用户名 / 班级 / 密码 / 上次登录 / 建号,
* 「重置密码」常驻、其余收进 ⋯。ID、真名(名字就在用户名里)、占位邮箱不再单列,搜索照样搜得到
*/
const message = useMessage()
const theme = useThemeVars()
const configStore = useConfigStore()
interface UserQuery {
keyword: string
type: string
orderBy: string
className: string
}
// 使用分页 composable
@@ -27,21 +37,31 @@ const { query, clearQuery } = usePagination<UserQuery>({
keyword: useRouteQuery("keyword", "").value,
type: useRouteQuery("type", "").value,
orderBy: useRouteQuery("orderBy", "").value,
className: useRouteQuery("className", "").value,
})
const classOptions = computed(() => [
{ label: "全部班级", value: "" },
...groupClassOptions(configStore.config?.classList ?? []),
{ label: classLabel(NO_CLASS), value: NO_CLASS },
])
const neverLoggedIn = ref(0)
const total = ref(0)
const users = ref<AdminUserForm[]>([])
const userEditing = ref<AdminUserForm | null>(null)
const adminOptions = [
{ label: "全部用户", value: "" },
{ label: "全部角色", value: "" },
{ label: "学生", value: USER_TYPE.REGULAR_USER },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
]
const sortOptions = [
{ label: "默认排序", value: "" },
{ label: "最近建号", value: "" },
{ label: "按用户名", value: "username" },
{ label: "最近登录", value: "-last_login" },
{ label: "在线优先", value: "-online" },
]
@@ -54,20 +74,41 @@ const userIDs = ref<DataTableRowKey[]>([])
const rowKey = (row: AdminUserForm) => row.id
/** 上次登录:今天的写「今天 13:02」,在线的前面一个绿点;今年的写到月日;从没登录的写出来 */
function lastLoginText(row: AdminUserForm) {
if (!row.lastLogin) return h("span", { class: "muted" }, "从没登录")
const day = parseTime(row.lastLogin, "YYYY-MM-DD")
const today = parseTime(new Date().toISOString(), "YYYY-MM-DD")
const text =
day === today
? `今天 ${parseTime(row.lastLogin, "HH:mm")}`
: day.slice(0, 4) === today.slice(0, 4)
? parseTime(row.lastLogin, "MM-DD HH:mm")
: day
return h("span", { class: "num", title: parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") }, [
row.isOnline ? h("span", { class: "online", title: "在线" }) : null,
text,
])
}
const columns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 220,
minWidth: 200,
render: (row) => h(UserNameCell, { user: row }),
},
{
// 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码
title: "班级",
key: "className",
width: 130,
render: (row) => h("span", { class: "muted" }, row.className ? classLabel(row.className) : "—"),
},
{
title: "密码",
key: "raw_password",
width: 180,
width: 150,
render: (row) =>
h(PasswordCell, {
user: row,
@@ -77,36 +118,22 @@ const columns: DataTableColumn<AdminUserForm>[] = [
},
}),
},
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
{
title: "创建时间",
title: "建号",
key: "create_time",
width: 200,
render: (row) => (row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : ""),
},
{
title: "上次登录",
key: "last_login",
width: 240,
// 在线是 5 分钟内有过活动(后端 auth/presence.ts),不是「有会话」——
// 会话能留 7 天。上次登录时间照旧显示,在线的人前面多一个标记
width: 110,
render: (row) =>
h(NFlex, { align: "center", size: "small" }, () => [
row.isOnline ? h(NTag, { type: "success", size: "small" }, () => "在线") : null,
row.lastLogin ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") : "从未登录",
]),
h(
"span",
{ class: "num muted" },
row.createTime ? parseTime(row.createTime, "YYYY-MM-DD") : "",
),
},
{
title: "真名",
key: "real_name",
width: 100,
render: (row) => h(TextCopy, () => row.realName),
},
{ title: "邮箱", key: "email", width: 260 },
{
key: "actions",
title: "选项",
width: 280,
fixed: "right",
title: "",
width: 130,
render: (row) =>
h(Actions, {
user: row,
@@ -136,8 +163,16 @@ const teacherPermissionOptions = problemPermissionOptions.slice(1)
async function listUsers() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getUserList(offset, query.limit, query.type, query.keyword, query.orderBy)
const res = await getUserList(
offset,
query.limit,
query.type,
query.keyword,
query.orderBy,
query.className,
)
total.value = res.total
neverLoggedIn.value = res.neverLoggedIn
users.value = res.results
revealedPasswords.value.clear()
}
@@ -146,8 +181,14 @@ function chooseUsers(rowKeys: DataTableRowKey[]) {
userIDs.value = rowKeys
}
async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
await deleteUsers(toRaw(userIDs) as number[])
async function onDeleteUsers(ids: DataTableRowKey[] | Ref<number[]>) {
try {
await deleteUsers(toRaw(ids) as number[])
message.success("删掉了")
userIDs.value = []
} catch (err) {
message.error(errorMessage(err))
}
listUsers()
}
@@ -252,7 +293,7 @@ onMounted(listUsers)
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
watch(() => [query.page, query.limit, query.type, query.orderBy, query.className], listUsers)
</script>
<template>
@@ -262,38 +303,51 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<template #trigger>
<n-button type="error" secondary>删除选中({{ userIDs.length }})</n-button>
</template>
确定删除选中的用户吗?删除后无法恢复!
删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。
</n-popconfirm>
<n-button @click="$router.push({ name: 'admin user generate' })">批量导入</n-button>
<n-button type="primary" @click="createNewUser">新建用户</n-button>
<n-button @click="createNewUser">新建一个</n-button>
<n-button type="primary" @click="$router.push({ name: 'admin user generate' })">
批量建号
</n-button>
</template>
<template #filters>
<n-input
style="width: 220px"
v-model:value="query.keyword"
placeholder="用户名 / 真名 / 邮箱"
placeholder="用户名或名字"
clearable
@clear="clearQuery"
/>
<n-select
v-model:value="query.className"
:options="classOptions"
filterable
aria-label="班级"
style="width: 150px"
/>
<n-select
v-model:value="query.type"
:options="adminOptions"
placeholder="选择用户类型"
aria-label="角色"
style="width: 120px"
/>
<n-select
v-model:value="query.orderBy"
:options="sortOptions"
placeholder="排序方式"
aria-label="排序"
style="width: 120px"
/>
<n-text depth="3">
{{ total }} 人<template v-if="neverLoggedIn"> · {{ neverLoggedIn }} 人从没登录</template>
</n-text>
</template>
</PageHeader>
<n-data-table
class="user-table"
:data="users"
:columns="columns"
striped
:row-key="rowKey"
:checked-row-keys="userIDs"
@update:checked-row-keys="chooseUsers"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
@@ -357,3 +411,24 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
</n-form>
</n-modal>
</template>
<style scoped>
.user-table :deep(.muted) {
color: v-bind("theme.textColor3");
}
.user-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.user-table :deep(.online) {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: v-bind("theme.successColor");
vertical-align: 1px;
}
</style>
+6 -3
View File
@@ -78,10 +78,13 @@ const tags = ref<Tag[]>([])
const allCount = ref(0)
const progress = ref<ProblemProgress | null>(null)
/** 老师看的是题库,没有状态列、没有个人进度(定稿「老师」那块) */
/**
* 老师看的是题库,没有个人进度(定稿「老师」那块)。状态列(做对打勾、做过没对一道杠)
* 老师也有:2026-10-13 用户要的,自己做过哪些题也想一眼看到
*/
const teacher = computed(() => userStore.isTeacherOrAbove)
const showStatus = computed(() => userStore.isAuthed && !teacher.value)
const mine = computed(() => (showStatus.value ? progress.value : null))
const showStatus = computed(() => userStore.isAuthed)
const mine = computed(() => (showStatus.value && !teacher.value ? progress.value : null))
// 老师那一版没有这个开关:没有状态列,开了也看不出藏了哪些题。带着 ?undone=1 进来也当没开
const undoneOn = computed({
+3
View File
@@ -189,6 +189,9 @@ export interface AdminProblemRow {
showFlowchart: boolean
// 比赛题目列表恒为 null —— 只有公开题列表下发最高票评价
topReaction: { type: ReactionKey; count: number } | null
/** 做对 / 做过的人数(题库里的代码提交);比赛题列表恒为 0 */
solvedUsers: number
triedUsers: number
}
// 题单相关类型
+43 -3
View File
@@ -272,7 +272,18 @@ export const adminUserSchema = z.object({
className: z.string().nullable(),
})
export const adminUserListSchema = paginatedSchema(adminUserSchema)
export const adminUserListSchema = paginatedSchema(adminUserSchema).extend({
/** 同样筛选条件下从没登录过的人数(列表顶上「48 人 · 1 人从没登录」) */
neverLoggedIn: z.number().int(),
})
/** 批量建号之前查重:这几个用户名哪些已经有了 */
export const checkUsernamesRequestSchema = z.object({
usernames: z.array(z.string().trim().min(1).max(32)).min(1).max(500),
})
export const checkUsernamesResponseSchema = z.object({
existing: z.array(z.object({ username: z.string(), createTime: z.string().nullable() })),
})
export const updateUserRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
@@ -383,7 +394,13 @@ export const adminContestSchema = z.object({
contestType: z.enum(["Public", "Password Protected"]),
})
export const adminContestListSchema = paginatedSchema(adminContestSchema)
/** 后台比赛列表的一行:比赛本身,加几道题、几人参加(交过题的人数) */
export const adminContestListItemSchema = adminContestSchema.extend({
problemCount: z.number().int(),
participantCount: z.number().int(),
})
export const adminContestListSchema = paginatedSchema(adminContestListItemSchema)
export const createContestRequestSchema = z.object({
title: z.string().trim().min(1).max(128),
@@ -529,6 +546,12 @@ export const batchProblemTagRequestSchema = z.object({
action: z.enum(["add", "remove"]),
})
/** 勾了几道题,一次让学生看得到 / 藏起来。只认公开题、只认自己能改的 */
export const batchProblemVisibleRequestSchema = z.object({
problemIds: z.array(z.number().int().positive()).min(1),
visible: z.boolean(),
})
export const batchProblemTagResponseSchema = z.object({
problemCount: z.number().int(),
tagCount: z.number().int(),
@@ -699,9 +722,23 @@ export const adminProblemListItemSchema = z.object({
// 最高票评价 {type, count}。**只有公开题列表下发**,比赛题列表恒传 null ——
// 与旧后端 reaction/services.py:get_top_reactions 的口径一致。
topReaction: z.object({ type: reactionKeySchema, count: z.number().int() }).nullable(),
/**
* 做对 / 做过的人数(代码提交,和前台题目列表同一口径)。比赛题列表恒为 0 ——
* 那个口径只数题库里交的。做过的人数 > 0 就删不掉(有提交),列表里「删除」据此灰掉
*/
solvedUsers: z.number().int(),
triedUsers: z.number().int(),
})
export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema)
export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema).extend({
/**
* 「全部 / 学生看得到 / 藏着的」三个按钮上的数:除了可见性,筛选条件和列表一样。
* 只有公开题列表给,比赛题列表没有这组按钮
*/
counts: z
.object({ all: z.number().int(), visible: z.number().int(), hidden: z.number().int() })
.optional(),
})
/**
* 题面样例 / 标准答案 / 测试点分值。这三个旧后端都是逐字段校验的
@@ -977,8 +1014,10 @@ export type CreateProblemRequest = z.infer<typeof createProblemRequestSchema>
export type AdminContest = z.infer<typeof adminContestSchema>
export type AdminContestList = z.infer<typeof adminContestListSchema>
export type AdminContestListItem = z.infer<typeof adminContestListItemSchema>
export type AdminUser = z.infer<typeof adminUserSchema>
export type AdminUserList = z.infer<typeof adminUserListSchema>
export type CheckUsernamesResponse = z.infer<typeof checkUsernamesResponseSchema>
export type JudgeServer = z.infer<typeof judgeServerSchema>
export type DashboardInfo = z.infer<typeof dashboardInfoSchema>
export type JudgeServerList = z.infer<typeof judgeServerListSchema>
@@ -1058,6 +1097,7 @@ export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeR
export type UpdateProblemSetBadgeRequest = z.infer<typeof updateProblemSetBadgeRequestSchema>
export type RenameTagRequest = z.infer<typeof renameTagRequestSchema>
export type BatchProblemTagRequest = z.infer<typeof batchProblemTagRequestSchema>
export type BatchProblemVisibleRequest = z.infer<typeof batchProblemVisibleRequestSchema>
export type GenerateFlowchartRequest = z.infer<typeof generateFlowchartRequestSchema>
export type GenerateFlowchartResponse = z.infer<typeof generateFlowchartResponseSchema>
export type UpdateProblemRequest = z.infer<typeof updateProblemRequestSchema>