feat(admin): 班级号位数校验,规则收敛到 constants
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

后端现在会拒绝位数不对的班级号,前端提前拦一道并给出明确提示。

- generate.vue 导入前校验班级号范围
- config.vue 校验班级列表每一项,输入框下方补说明
- 位数规则收敛到 utils/constants 的 CLASS_NAME_* 常量,
  三个使用方都改为引用

顺带两处修正:

- config.vue 和 list.vue 的保存都没有 try/catch。http 拦截器只对
  login-required / permission-denied 自动弹提示,其余业务错误不接住
  就什么都不显示,连"用户名已存在"这类老错误也是静默的
- ButtonWithSearch 原来用 startsWith("ks") 决定显不显示按班级筛选,
  kstest 这类账号会显示按钮但点了没反应;改用同一个正则判断

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 00:15:52 -06:00
parent 0e2cc28235
commit 8a46bae27d
5 changed files with 95 additions and 20 deletions

View File

@@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Server } from "utils/types" import type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
} }
async function saveWebsiteConfig() { async function saveWebsiteConfig() {
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
if (invalid.length) {
message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return
}
try {
await editWebsite(websiteConfig) await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功") message.success("网站配置保存成功")
getWebsiteConfig() getWebsiteConfig()
configStore.getConfig() configStore.getConfig()
@@ -214,7 +233,13 @@ onMounted(() => {
</n-form> </n-form>
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="班级列表"> <n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags v-model:value="websiteConfig.class_list" /> <n-dynamic-tags v-model:value="websiteConfig.class_list" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-flex align="center"> <n-flex align="center">

View File

@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api" import { importUsers } from "../api"
const message = useMessage() const message = useMessage()
@@ -12,6 +18,17 @@ function generateUsers() {
message.info("请填写相关内容") message.info("请填写相关内容")
return false return false
} }
// 后端 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
}
let className = !!prefix.value ? `ks${prefix.value}` : "" let className = !!prefix.value ? `ks${prefix.value}` : ""
rawInput.value = rawInput.value.trim() rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n") const inputs = rawInput.value.split("\n")
@@ -64,9 +81,9 @@ async function submit() {
style="width: 170px" style="width: 170px"
v-model:value="prefix" v-model:value="prefix"
clearable clearable
:max="9999" :max="CLASS_NAME_MAX_VALUE"
:min="0" :min="0"
placeholder="班级号" :placeholder="`班级号${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
/> />
</n-flex> </n-flex>
<n-input <n-input

View File

@@ -196,6 +196,9 @@ async function handleEditUser() {
message.error("密码长度不得小于 6") message.error("密码长度不得小于 6")
return return
} }
// http 拦截器只对 login-required / permission-denied 自动弹提示,
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
try {
if (create.value) { if (create.value) {
const newUser = [ const newUser = [
[ [
@@ -208,9 +211,15 @@ async function handleEditUser() {
await importUsers(newUser) await importUsers(newUser)
listUsers() listUsers()
} else { } else {
const user = Object.assign(userEditing.value, { password: password.value }) const user = Object.assign(userEditing.value, {
password: password.value,
})
await editUser(user) await editUser(user)
} }
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
userEditing.value = null userEditing.value = null
password.value = "" password.value = ""
toggleCreate(false) toggleCreate(false)

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props { interface Props {
type: "题目" | "用户" type: "题目" | "用户"
@@ -10,12 +11,14 @@ const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"]) const emits = defineEmits(["click", "search", "filterClass"])
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
// 也显示出按钮,点了却什么都不发生
const showFilterClass = computed(() => { const showFilterClass = computed(() => {
return props.type === "用户" && props.username?.startsWith("ks") return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
}) })
function filterClass() { function filterClass() {
const match = props.username!.match(/^ks\d{3,4}/) const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : "" const classname = match ? match[0] : ""
if (!classname) return if (!classname) return
emits("filterClass", classname) emits("filterClass", classname)

View File

@@ -325,3 +325,24 @@ export const DURATION_OPTIONS = [
{ label: "半年内", value: "months:6" }, { label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" }, { label: "一年内", value: "years:1" },
] as const ] as const
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
// 网站配置里的班级列表对得上。
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
// 是同一条规则的另一份,改这里必须同时改那边。
export const CLASS_NAME_MIN_DIGITS = 3
export const CLASS_NAME_MAX_DIGITS = 4
/** 合法班级号3~4 位纯数字 */
export const CLASS_NAME_RE = new RegExp(
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
)
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
export const USERNAME_CLASS_RE = new RegExp(
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
)
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1