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
+70 -58

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 "md-editor-v3/lib/preview.css"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getClassBattle, getClassPk } from "oj/api" 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 { useAIStream } from "shared/composables/aiStream"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
@@ -102,25 +102,13 @@ function setPeriod(value: ClassPkPeriod) {
} }
/** 「+ 加一个班」:这学期在用的班(班级对抗里有的),按年级分组,自己年级放最前 */ /** 「+ 加一个班」:这学期在用的班(班级对抗里有的),按年级分组,自己年级放最前 */
const addOptions = computed(() => { const addOptions = computed(() =>
const grade = selected.value[0]?.slice(0, 2) groupClassOptions(
const groups = new Map<string, { label: string; value: string }[]>() battle.value.map((item) => item.className).filter((name) => !selected.value.includes(name)),
for (const item of battle.value) { undefined,
if (selected.value.includes(item.className)) continue selected.value[0]?.slice(0, 2),
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 mode = computed(() => { const mode = computed(() => {
if (!pk.value || pk.value.classes.length < 2) return "empty" if (!pk.value || pk.value.classes.length < 2) return "empty"
+2 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api" import { getClassBoard, setClassLesson } from "oj/api"
import { groupClassOptions } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import { useLeaveStudents } from "shared/composables/leaveStudents" import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
@@ -49,17 +50,9 @@ const languageOptions = LESSON_LANGUAGES.map((value) => ({
})) }))
const saving = ref(false) 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 classOptions = computed(() => {
const list = configStore.config?.classList ?? [] const list = configStore.config?.classList ?? []
const names = return groupClassOptions(board.value?.className ? [board.value.className, ...list] : list)
board.value?.className && !list.includes(board.value.className)
? [board.value.className, ...list]
: list
return names.map((name) => ({ label: classLabel(name), value: name }))
}) })
async function load() { async function load() {
@@ -2,6 +2,7 @@
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getProblemSetClassView } from "oj/api" import { getProblemSetClassView } from "oj/api"
import { useTone } from "oj/submission/composables/tone" import { useTone } from "oj/submission/composables/tone"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { ProblemSetClassView } from "utils/types" import type { ProblemSetClassView } from "utils/types"
@@ -35,16 +36,13 @@ watch(className, (value, old) => {
if (old && value !== data.value?.className) load() if (old && value !== data.value?.className) load()
}) })
function classLabel(name: string) { const classOptions = computed(() => {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name const classes = new Map((data.value?.classes ?? []).map((item) => [item.className, item]))
} return groupClassOptions([...classes.keys()], (name) => {
const item = classes.get(name)!
const classOptions = computed(() => return { value: name, label: `${classLabel(name)}(${item.joined}/${item.size} 人加入)` }
(data.value?.classes ?? []).map((item) => ({ })
value: item.className, })
label: `${classLabel(item.className)}(${item.joined}/${item.size} 人加入)`,
})),
)
const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? []) const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? [])
const absent = 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" } from "@oj2/contract"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { clearMood, getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from "oj/api" 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 UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
@@ -261,12 +261,8 @@ const minis = computed<MiniRank[]>(() => {
const teacherClassOptions = computed(() => { const teacherClassOptions = computed(() => {
const list = configStore.config?.classList ?? [] const list = configStore.config?.classList ?? []
const all = const all = teacherClass.value ? [teacherClass.value, ...list] : list
teacherClass.value && !list.includes(teacherClass.value) ? [teacherClass.value, ...list] : list return [{ label: "全部", value: "" }, ...groupClassOptions(all)]
return [
{ label: "全部", value: "" },
...all.map((name) => ({ label: classLabel(name), value: name })),
]
}) })
const myBattle = computed(() => battle.value.find((item) => item.className === classContext.value)) 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) { export function classLabel(className: string | null) {
if (className === null || className === NO_CLASS) return "没填班级" if (className === null || className === NO_CLASS) return "没填班级"
if (!/^\d{3,}$/.test(className)) return className
return `${className.slice(0, 2)}计算机${className.slice(2)}班` 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) { export function classSelectOptions(list: string[], current: string) {
const all = current && current !== NO_CLASS && !list.includes(current) ? [current, ...list] : list const all = current && current !== NO_CLASS ? [current, ...list] : list
return [...all, NO_CLASS].map((item) => ({ label: classLabel(item), value: item })) return [...groupClassOptions(all), { label: classLabel(NO_CLASS), value: NO_CLASS }]
} }
+9 -8
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { errorCode } from "utils/api" import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
@@ -52,14 +53,13 @@ function filterClassUser(pattern: string, option: SelectOption) {
// 空串是「没有我所在的班级」,null 才是没选 —— 往届的班级毕业后会从后台配置里删掉, // 空串是「没有我所在的班级」,null 才是没选 —— 往届的班级毕业后会从后台配置里删掉,
// 这一项是他们唯一的入口,写完整用户名登录 // 这一项是他们唯一的入口,写完整用户名登录
const OTHER_CLASS = "" const OTHER_CLASS = ""
const classList = computed<SelectOption[]>(() => { const classList = computed<SelectOption[]>(() => [
const configs = ...groupClassOptions(configStore.config?.classList ?? [], (item) => ({
configStore.config?.classList.map((item) => ({ label: classLabel(item),
label: `${item.slice(0, 2)}计算机${item.slice(2)}班`,
value: `ks${item}`, value: `ks${item}`,
})) ?? [] })),
return [...configs, { label: "没有我所在的班级", value: OTHER_CLASS }] { label: "没有我所在的班级", value: OTHER_CLASS },
}) ])
// 选了具体班级就是「选姓名」,选了「没有我所在的班级」就是「写用户名」 // 选了具体班级就是「选姓名」,选了「没有我所在的班级」就是「写用户名」
const isClassLogin = computed(() => Boolean(form.value.class)) const isClassLogin = computed(() => Boolean(form.value.class))
const passwordRule: FormItemRule[] = [ const passwordRule: FormItemRule[] = [
@@ -190,7 +190,8 @@ function onUsernamePicked() {
function restoreLastClass() { function restoreLastClass() {
if (form.value.class !== null) return if (form.value.class !== null) return
const last = storage.get(STORAGE_KEY.LOGIN_CLASS) 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 form.value.class = last
} }
} }