feat(班级下拉): 全站的班级下拉按年级分组,新年级在上,组里按班号排
Deploy / deploy (push) Canceled after 0s

登录框、提交列表、数据统计、排名、课堂看板、题单班级视图、班级 PK 七处统一走
groupClassOptions。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 12:54:26 +08:00
1 parent 1e441411a5
commit 367cfc9d5d
6 files changed
+71 -59

No files matched your search

+8 -20
View File
@@ -4,7 +4,7 @@ import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useThemeVars } from "naive-ui"
import { getClassBattle, getClassPk } from "oj/api"
import { classLabel } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { useAIStream } from "shared/composables/aiStream"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
@@ -102,25 +102,13 @@ function setPeriod(value: ClassPkPeriod) {
}
/** 「+ 加一个班」:这学期在用的班(班级对抗里有的),按年级分组,自己年级放最前 */
const addOptions = computed(() => {
const grade = selected.value[0]?.slice(0, 2)
const groups = new Map<string, { label: string; value: string }[]>()
for (const item of battle.value) {
if (selected.value.includes(item.className)) continue
const key = item.className.slice(0, 2)
const list = groups.get(key) ?? []
list.push({ label: classLabel(item.className), value: item.className })
groups.set(key, list)
}
return [...groups]
.sort((a, b) => (a[0] === grade ? -1 : b[0] === grade ? 1 : b[0].localeCompare(a[0])))
.map(([key, children]) => ({
type: "group" as const,
label: `${key} 级`,
key,
children: children.sort((a, b) => a.value.localeCompare(b.value, "zh", { numeric: true })),
}))
})
const addOptions = computed(() =>
groupClassOptions(
battle.value.map((item) => item.className).filter((name) => !selected.value.includes(name)),
undefined,
selected.value[0]?.slice(0, 2),
),
)
const mode = computed(() => {
if (!pk.value || pk.value.classes.length < 2) return "empty"
+2 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api"
import { groupClassOptions } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useCollabStore } from "shared/store/collab"
@@ -49,17 +50,9 @@ const languageOptions = LESSON_LANGUAGES.map((value) => ({
}))
const saving = ref(false)
function classLabel(name: string) {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
}
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
const names =
board.value?.className && !list.includes(board.value.className)
? [board.value.className, ...list]
: list
return names.map((name) => ({ label: classLabel(name), value: name }))
return groupClassOptions(board.value?.className ? [board.value.className, ...list] : list)
})
async function load() {
@@ -2,6 +2,7 @@
import { useThemeVars } from "naive-ui"
import { getProblemSetClassView } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions"
import type { ProblemSetClassView } from "utils/types"
@@ -35,16 +36,13 @@ watch(className, (value, old) => {
if (old && value !== data.value?.className) load()
})
function classLabel(name: string) {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
}
const classOptions = computed(() =>
(data.value?.classes ?? []).map((item) => ({
value: item.className,
label: `${classLabel(item.className)}(${item.joined}/${item.size} 人加入)`,
})),
)
const classOptions = computed(() => {
const classes = new Map((data.value?.classes ?? []).map((item) => [item.className, item]))
return groupClassOptions([...classes.keys()], (name) => {
const item = classes.get(name)!
return { value: name, label: `${classLabel(name)}(${item.joined}/${item.size} 人加入)` }
})
})
const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? [])
const absent = computed(() => data.value?.students.filter((s) => !s.joinTime) ?? [])
+3 -7
View File
@@ -9,7 +9,7 @@ import type {
} from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { clearMood, getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from "oj/api"
import { classLabel } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useConfigStore } from "shared/store/config"
@@ -261,12 +261,8 @@ const minis = computed<MiniRank[]>(() => {
const teacherClassOptions = computed(() => {
const list = configStore.config?.classList ?? []
const all =
teacherClass.value && !list.includes(teacherClass.value) ? [teacherClass.value, ...list] : list
return [
{ label: "全部", value: "" },
...all.map((name) => ({ label: classLabel(name), value: name })),
]
const all = teacherClass.value ? [teacherClass.value, ...list] : list
return [{ label: "全部", value: "" }, ...groupClassOptions(all)]
})
const myBattle = computed(() => battle.value.find((item) => item.className === classContext.value))
+40 -4
View File
@@ -40,14 +40,50 @@ export function submissionDayText(time: string) {
*/
export function classLabel(className: string | null) {
if (className === null || className === NO_CLASS) return "没填班级"
if (!/^\d{3,}$/.test(className)) return className
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
type ClassOption = { label: string; value: string }
/**
* 班级下拉的选项:配置里的班,最后一项「没填班级」(这批号也要能和普通班一样圈出来看)。
* 带进来的班不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
* 全站的班级下拉都按年级分组:「25 级」底下列 251、252……,新的年级在上,组里按班号排
* (11 班排在 2 班后面)。年级是班号前两位,和排名的「本年级」同一个口径。
* `first` 给了就把那个年级提到最前(班级 PK 加班时先给同年级的)。
* `toOption` 换掉选项长相(题单的「n/m 人加入」、登录框的 ks 前缀);不是数字班号的
* 名字不分组,按原顺序跟在最后。
*/
export function groupClassOptions<T extends ClassOption = ClassOption>(
names: string[],
toOption: (name: string) => T = (name) => ({ label: classLabel(name), value: name }) as T,
first?: string,
) {
const groups = new Map<string, string[]>()
const rest: string[] = []
for (const name of new Set(names)) {
if (!/^\d{3,}$/.test(name)) {
rest.push(name)
continue
}
const grade = name.slice(0, 2)
groups.set(grade, [...(groups.get(grade) ?? []), name])
}
const grouped = [...groups]
.sort((a, b) => (a[0] === first ? -1 : b[0] === first ? 1 : b[0].localeCompare(a[0])))
.map(([grade, list]) => ({
type: "group" as const,
label: `${grade} 级`,
key: `grade-${grade}`,
children: list.sort((a, b) => a.localeCompare(b, "zh", { numeric: true })).map(toOption),
}))
return [...grouped, ...rest.map(toOption)]
}
/**
* 班级下拉的选项:配置里的班按年级分组,最后一项「没填班级」(这批号也要能和普通班一样
* 圈出来看)。带进来的班不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
*/
export function classSelectOptions(list: string[], current: string) {
const all = current && current !== NO_CLASS && !list.includes(current) ? [current, ...list] : list
return [...all, NO_CLASS].map((item) => ({ label: classLabel(item), value: item }))
const all = current && current !== NO_CLASS ? [current, ...list] : list
return [...groupClassOptions(all), { label: classLabel(NO_CLASS), value: NO_CLASS }]
}
+10 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
@@ -52,14 +53,13 @@ function filterClassUser(pattern: string, option: SelectOption) {
// 空串是「没有我所在的班级」,null 才是没选 —— 往届的班级毕业后会从后台配置里删掉,
// 这一项是他们唯一的入口,写完整用户名登录
const OTHER_CLASS = ""
const classList = computed<SelectOption[]>(() => {
const configs =
configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}班`,
value: `ks${item}`,
})) ?? []
return [...configs, { label: "没有我所在的班级", value: OTHER_CLASS }]
})
const classList = computed<SelectOption[]>(() => [
...groupClassOptions(configStore.config?.classList ?? [], (item) => ({
label: classLabel(item),
value: `ks${item}`,
})),
{ label: "没有我所在的班级", value: OTHER_CLASS },
])
// 选了具体班级就是「选姓名」,选了「没有我所在的班级」就是「写用户名」
const isClassLogin = computed(() => Boolean(form.value.class))
const passwordRule: FormItemRule[] = [
@@ -190,7 +190,8 @@ function onUsernamePicked() {
function restoreLastClass() {
if (form.value.class !== null) return
const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
if (typeof last === "string" && classList.value.some((item) => item.value === last)) {
const known = (configStore.config?.classList ?? []).map((item) => `ks${item}`)
if (typeof last === "string" && known.includes(last)) {
form.value.class = last
}
}