From 871861220e54e082fc58b1d15da4e335e39abb9f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 16 Oct 2026 15:48:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E6=8F=90=E4=BA=A4=E5=88=97=E8=A1=A8):?= =?UTF-8?q?=20=E9=80=89=E4=B8=AD=E7=BF=BB=E9=98=85=E3=80=81=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=9B=B4=E6=96=B0=E3=80=81=E3=80=8C=E8=BF=99=E8=8A=82?= =?UTF-8?q?=E8=AF=BE=E3=80=8D=E3=80=81=E5=B8=A6=E6=9D=A1=E4=BB=B6=E5=8E=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=8B=86=E6=88=90=E5=9B=9B=E4=B8=AA=20compos?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list.vue 从 1834 行减到 1604 行,拆出 oj/submission/composables/ 下的 rowCursor(↑ ↓ 换条、页头页尾自动翻页)、listLive(正在评分的 3 秒刷新、第一页的 「有 N 条新提交」)、lessonFilter(「这节课」一键筛)、statsLink(和统计之间来回带条件)。 注释原样跟着走。 纯搬家。七个状态(列表、↓↓、↑、流程图、我的、手机列表、手机详情)改前改后截图一致; 连按 31 下 ↓ 翻到第 2 页,别人交一条后 18 秒内出「有 1 条新提交」,「这节课」筛到 班级 + 题号 + 今天。 Co-Authored-By: Claude Opus 5.5 --- .../oj/submission/composables/lessonFilter.ts | 61 ++++ .../src/oj/submission/composables/listLive.ts | 90 +++++ .../oj/submission/composables/listQuery.ts | 17 + .../oj/submission/composables/rowCursor.ts | 131 +++++++ .../oj/submission/composables/statsLink.ts | 80 +++++ apps/web/src/oj/submission/list.vue | 321 +++--------------- 6 files changed, 424 insertions(+), 276 deletions(-) create mode 100644 apps/web/src/oj/submission/composables/lessonFilter.ts create mode 100644 apps/web/src/oj/submission/composables/listLive.ts create mode 100644 apps/web/src/oj/submission/composables/listQuery.ts create mode 100644 apps/web/src/oj/submission/composables/rowCursor.ts create mode 100644 apps/web/src/oj/submission/composables/statsLink.ts diff --git a/apps/web/src/oj/submission/composables/lessonFilter.ts b/apps/web/src/oj/submission/composables/lessonFilter.ts new file mode 100644 index 0000000..ad22940 --- /dev/null +++ b/apps/web/src/oj/submission/composables/lessonFilter.ts @@ -0,0 +1,61 @@ +import { computed, ref, type Ref } from "vue" +import { getClassLesson } from "oj/api" +import { classLabel } from "utils/classes" +import type { ClassLesson } from "utils/types" +import type { SubmissionQuery } from "./listQuery" + +/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */ +export function useLessonFilter(options: { + query: SubmissionQuery + flowMode: Ref + info: (text: string) => void +}) { + const { query } = options + const lesson = ref(null) + const lessonIds = computed( + () => lesson.value?.problems.map((problem) => problem.problemDisplayId).join(",") ?? "", + ) + const lessonActive = computed( + () => + !!lesson.value?.className && + !!lesson.value.problems.length && + query.className === lesson.value.className && + query.problem === lessonIds.value && + query.today === "1", + ) + const lessonLoading = ref(false) + + async function toggleLesson() { + if (lessonActive.value) { + query.problem = "" + query.today = "0" + // 「这节课」顺带筛上的语言一起放掉;老师自己另选的别动 + if (lesson.value?.language && query.language === lesson.value.language) query.language = "" + return + } + lessonLoading.value = true + try { + const res = await getClassLesson(query.className || undefined) + if (!res.className) { + options.info("猜不出是哪个班在上课,先在班级里选一个") + return + } + query.className = res.className + if (!res.problems.length) { + options.info( + `${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`, + ) + return + } + lesson.value = res + query.problem = lessonIds.value + query.today = "1" + // 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动) + if (res.language && !options.flowMode.value) query.language = res.language + } finally { + lessonLoading.value = false + } + } + + return { lesson, lessonActive, lessonLoading, toggleLesson } +} diff --git a/apps/web/src/oj/submission/composables/listLive.ts b/apps/web/src/oj/submission/composables/listLive.ts new file mode 100644 index 0000000..a91dad3 --- /dev/null +++ b/apps/web/src/oj/submission/composables/listLive.ts @@ -0,0 +1,90 @@ +import { computed, ref, type Ref } from "vue" +import { useDocumentVisibility, useIntervalFn } from "@vueuse/core" +import { SubmissionStatus } from "utils/constants" +import { + FlowchartSubmissionStatus, + type FlowchartSubmissionListItem, + type SubmissionListItem, +} from "utils/types" +import type { Row, SubmissionQuery } from "./listQuery" + +function isPending(row: Row) { + if ("result" in row) { + return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging + } + return ( + row.status === FlowchartSubmissionStatus.PENDING || + row.status === FlowchartSubmissionStatus.PROCESSING + ) +} + +/** + * 提交列表的实时更新。两件事,一个定时器: + * - 本页有「正在评分」的,3 秒拉一次,把结果原地换上; + * - 第一页每 15 秒看一眼有没有新交的。**新的不直接插进来** —— 老师正看着第 5 行, + * 上面突然塞进 3 行,选中的那行就跑了。顶上给一条「有 N 条新提交」,点了才换。 + * 页面切到后台就停。 + * + * `requestSeq` 是列表那边的请求序号:拉的这期间筛选变了、翻页了,这份结果就作废。 + */ +export function useListLive(options: { + query: SubmissionQuery + flowMode: Ref + rows: Ref + submissions: Ref + flowcharts: Ref + loading: Ref + loaded: Ref + fetchPage: () => Promise<{ results: Row[] }> + requestSeq: () => number +}) { + const { query, flowMode, rows, submissions, flowcharts } = options + const newCount = ref(0) + const newCountCapped = computed(() => newCount.value >= query.limit) + let lastPoll = Date.now() + const visibility = useDocumentVisibility() + + const hasPending = computed(() => rows.value.some(isPending)) + + useIntervalFn(async () => { + if (visibility.value !== "visible" || options.loading.value || !options.loaded.value) return + const interval = hasPending.value ? 3000 : 15000 + if (Date.now() - lastPoll < interval) return + if (!hasPending.value && query.page !== 1) return + lastPoll = Date.now() + const seq = options.requestSeq() + const mode = flowMode.value + let res + try { + res = await options.fetchPage() + } catch { + return + } + // 这期间筛选变了、翻页了,这份结果就作废 + if (seq !== options.requestSeq() || mode !== flowMode.value) return + const fresh = new Map(res.results.map((row) => [row.id, row])) + if (mode) { + flowcharts.value = flowcharts.value.map( + (row) => (fresh.get(row.id) as FlowchartSubmissionListItem | undefined) ?? row, + ) + } else { + submissions.value = submissions.value.map( + (row) => (fresh.get(row.id) as SubmissionListItem | undefined) ?? row, + ) + } + if (query.page === 1) { + const current = new Set(rows.value.map((row) => row.id)) + const newest = rows.value[0]?.createTime ?? "" + newCount.value = res.results.filter( + (row) => !current.has(row.id) && row.createTime > newest, + ).length + } + }, 1000) + + /** 刚点了重新判题 / 重新评分:下一秒就拉,不等满 3 秒 */ + function pollSoon() { + lastPoll = 0 + } + + return { newCount, newCountCapped, pollSoon } +} diff --git a/apps/web/src/oj/submission/composables/listQuery.ts b/apps/web/src/oj/submission/composables/listQuery.ts new file mode 100644 index 0000000..066598c --- /dev/null +++ b/apps/web/src/oj/submission/composables/listQuery.ts @@ -0,0 +1,17 @@ +import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types" + +/** 提交列表(list.vue)的筛选条件,和它拆出去的几个 composable 共用 */ +export interface SubmissionQuery { + page: number + limit: number + username: string + className: string + result: string + myself: "0" | "1" + problem: string + language: LANGUAGE | "" | "Flowchart" + today: "0" | "1" +} + +/** 一行:代码提交或流程图提交(看 query.language 是不是 Flowchart) */ +export type Row = SubmissionListItem | FlowchartSubmissionListItem diff --git a/apps/web/src/oj/submission/composables/rowCursor.ts b/apps/web/src/oj/submission/composables/rowCursor.ts new file mode 100644 index 0000000..dc4da23 --- /dev/null +++ b/apps/web/src/oj/submission/composables/rowCursor.ts @@ -0,0 +1,131 @@ +import { computed, nextTick, ref, type Ref } from "vue" +import { onKeyStroke } from "@vueuse/core" +import type { Row, SubmissionQuery } from "./listQuery" + +/** + * 提交列表的「选中与翻阅」:右栏跟着选中的那条走,↑ ↓ 换条,到了页头页尾自动翻页。 + * + * 翻页是异步的:先记下「落地后停在哪一头」(pendingJump),新一页回来之后由列表调 + * `settleSelection()` 再选。 + */ +export function useRowCursor(options: { + rows: Ref + total: Ref + query: SubmissionQuery + loading: Ref + isMobile: Ref + isDesktop: Ref + /** 开着弹框时方向键不归列表 */ + keysBlocked: () => boolean + /** 地址里带着的 ?id=(手机上开着详情时刷新、别人发来的链接) */ + wantedId: () => string + /** 这一页上没有 ?id= 那一条:详情开不出来,让列表把它从地址上摘掉 */ + dropWanted: () => void + /** 跨页落地后这一页一条能看的都没有 */ + onNothingToShow: () => void +}) { + const { rows, total, query, loading, isMobile, isDesktop } = options + + const selectedId = ref("") + const selectedRow = computed(() => rows.value.find((row) => row.id === selectedId.value) ?? null) + + /** 方向键只在能看的行之间走:跳到一条看不了的上面只会看到一把锁 */ + const navigable = computed(() => rows.value.filter((row) => row.showLink)) + const position = computed(() => { + const index = navigable.value.findIndex((row) => row.id === selectedId.value) + return index === -1 ? null : { index: index + 1, count: navigable.value.length } + }) + + // 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选 + let pendingJump: "first" | "last" | null = null + + /** 加载失败了别把「落地后停在哪一头」留给下一次不相干的加载 */ + function forgetJump() { + pendingJump = null + } + + function settleSelection() { + const jump = pendingJump + pendingJump = null + const list = navigable.value + if (jump) { + const row = jump === "first" ? list[0] : list[list.length - 1] + if (row) { + select(row.id) + return + } + options.onNothingToShow() + } + // 地址里带着 ?id= 的(手机上开着详情时刷新、别人发来的链接)就停在那一条 + const wanted = options.wantedId() + if (wanted && rows.value.some((row) => row.id === wanted)) { + selectedId.value = wanted + return + } + // 这一页上没有那一条:详情开不出来,把 ?id= 摘掉 + options.dropWanted() + if (rows.value.some((row) => row.id === selectedId.value)) return + // 手机上没有右栏,不用预先选一条 + if (isMobile.value) { + selectedId.value = "" + return + } + selectedId.value = (list[0] ?? rows.value[0])?.id ?? "" + } + + function select(id: string) { + selectedId.value = id + nextTick(() => { + document + .querySelector(`[data-row-id="${CSS.escape(id)}"]`) + ?.scrollIntoView({ block: "nearest" }) + }) + } + + const maxPage = computed(() => Math.max(1, Math.ceil(total.value / query.limit))) + + function move(step: 1 | -1) { + // 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来 + if (!navigable.value.length) return + // 上一次跨页还没回来:按住 ↓ 或者在页尾连按两下,不然会整页跳过 + if (loading.value || pendingJump) return + const all = rows.value + const start = all.findIndex((row) => row.id === selectedId.value) + for (let i = start + step; i >= 0 && i < all.length; i += step) { + if (all[i]!.showLink) { + select(all[i]!.id) + return + } + } + const page = query.page + step + if (page < 1 || page > maxPage.value) return + pendingJump = step > 0 ? "first" : "last" + query.page = page + } + + // 只用 ↑ ↓(用户定的,← → 不要) + onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => { + if (!isDesktop.value || options.keysBlocked()) return + // 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项) + if (e.defaultPrevented) return + const target = e.target as HTMLElement | null + if (target) { + // 「代码 / 流程图」「全部 / 我的」是单选框:点过之后焦点停在上面,浏览器默认会拿方向键 + // 切换选项 —— 老师想看下一条,结果模式被切了。单选框照常换条,下面的 preventDefault 挡掉切换 + const radio = target.tagName === "INPUT" && (target as HTMLInputElement).type === "radio" + // 焦点在输入框、下拉框、弹框里时不抢方向键 + if ( + (!radio && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) || + target.isContentEditable || + target.closest(".n-base-selection, .n-base-select-menu, .n-dropdown-menu, .n-modal") + ) { + return + } + } + if (!rows.value.length) return + e.preventDefault() + move(e.key === "ArrowUp" ? -1 : 1) + }) + + return { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } +} diff --git a/apps/web/src/oj/submission/composables/statsLink.ts b/apps/web/src/oj/submission/composables/statsLink.ts new file mode 100644 index 0000000..33f1826 --- /dev/null +++ b/apps/web/src/oj/submission/composables/statsLink.ts @@ -0,0 +1,80 @@ +import { reactive, type Ref } from "vue" +import { useRouter } from "vue-router" +import { DEFAULT_PERIOD } from "oj/statistics/period" +import type { SubmissionQuery } from "./listQuery" + +/** + * 提交列表和「统计」之间来回带条件:打开统计时带上列表现在的班级 / 学生 / 题号 / + * 代码或流程图,统计里点「在提交列表里看」时把条件改回列表上。 + */ +export function useStatsLink(options: { + query: SubmissionQuery + flowMode: Ref + statsPanel: Ref + /** 列表的「按整名只看一个人」,从统计回来时要放掉 */ + exactFor: Ref +}) { + const { query, flowMode, statsPanel } = options + const router = useRouter() + + /** + * 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图 + * 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的 + */ + const statsQuery = reactive({ + tab: "code", + className: "", + username: "", + problem: "", + period: DEFAULT_PERIOD as string, + from: "", + to: "", + }) + + /** + * 老师的「统计」:带着这张列表现在的班级 / 学生 / 题号 / 代码或流程图打开。原来旁边还有一颗 + * 「今日统计」,和「数据统计」名字分不清(用户说的),老师那一版并进来了 —— 什么都没筛时 + * 打开就是「一行一节课」,选「今天」就是原来今日统计看的东西 + */ + function openStatistics() { + Object.assign(statsQuery, { + tab: flowMode.value ? "flow" : "code", + className: query.className, + username: query.username, + problem: query.problem, + ...(query.today === "1" ? { period: "today" } : {}), + }) + statsPanel.value = true + } + + /** 手机上的「统计」:同样的条件,带去统计页 */ + function openStatisticsPage() { + router.push({ + name: "statistics", + query: { + tab: flowMode.value ? "flow" : "code", + ...(query.className ? { className: query.className } : {}), + ...(query.username ? { username: query.username } : {}), + ...(query.problem ? { problem: query.problem } : {}), + ...(query.today === "1" ? { period: "today" } : {}), + }, + }) + } + + /** 统计里的「在提交列表里看」:直接改背后这张列表的筛选,关掉弹框 */ + function listFromStatistics(next: Record) { + statsPanel.value = false + options.exactFor.value = "" + Object.assign(query, { + username: next.username ?? "", + className: next.className ?? "", + result: "", + myself: "0", + problem: next.problem ?? "", + language: next.language === "Flowchart" ? "Flowchart" : flowMode.value ? "" : query.language, + today: next.today === "1" ? "1" : "0", + }) + } + + return { statsQuery, openStatistics, openStatisticsPage, listFromStatistics } +} diff --git a/apps/web/src/oj/submission/list.vue b/apps/web/src/oj/submission/list.vue index 2fa5022..bba77f6 100644 --- a/apps/web/src/oj/submission/list.vue +++ b/apps/web/src/oj/submission/list.vue @@ -5,13 +5,11 @@ import { useRouteQuery } from "@vueuse/router" import { errorCode } from "utils/api" import { adminRejudge, - getClassLesson, getFlowchartSubmissions, getSubmissions, getTodaySubmissionCount, retryFlowchartSubmission, } from "oj/api" -import { DEFAULT_PERIOD } from "oj/statistics/period" import { useContestStore } from "oj/store/contest" import Pagination from "shared/components/Pagination.vue" import { useBackClose } from "shared/composables/backClose" @@ -21,12 +19,7 @@ import { useConfigStore } from "shared/store/config" import { useUserStore } from "shared/store/user" import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from "utils/constants" import { parseTime } from "utils/functions" -import type { - ClassLesson, - FlowchartSubmissionListItem, - LANGUAGE, - SubmissionListItem, -} from "utils/types" +import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types" import { FlowchartSubmissionStatus } from "utils/types" import FlowchartState from "./components/FlowchartState.vue" import StatusPill from "./components/StatusPill.vue" @@ -34,6 +27,11 @@ import UserName from "shared/components/UserName.vue" import { useTone } from "shared/composables/tone" import { classLabel, classSelectOptions } from "utils/classes" import { submissionClockText, submissionDayText } from "./utils" +import { useLessonFilter } from "./composables/lessonFilter" +import { useListLive } from "./composables/listLive" +import type { Row, SubmissionQuery } from "./composables/listQuery" +import { useRowCursor } from "./composables/rowCursor" +import { useStatsLink } from "./composables/statsLink" /** * 提交列表(设计稿「提交列表重设计 · 定稿」):左边列表、右边常驻选中那条的代码。 @@ -52,16 +50,6 @@ const StatisticsView = defineAsyncComponent( () => import("oj/statistics/components/StatisticsView.vue"), ) -interface SubmissionQuery { - username: string - className: string - result: string - myself: "0" | "1" - problem: string - language: LANGUAGE | "" | "Flowchart" - today: "0" | "1" -} - const route = useRoute() const router = useRouter() const userStore = useUserStore() @@ -87,7 +75,7 @@ let leavingPane = false const teacher = computed(() => userStore.isTeacherOrAbove) // 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好 -const { query, clearQuery } = usePagination( +const { query, clearQuery } = usePagination>( { username: useRouteQuery("username", "").value, className: useRouteQuery("className", "").value, @@ -138,7 +126,6 @@ watch(loading, (value) => { }) onUnmounted(() => clearTimeout(loadingTimer)) -type Row = SubmissionListItem | FlowchartSubmissionListItem const rows = computed(() => (flowMode.value ? flowcharts.value : submissions.value)) function fetchPage() { @@ -199,7 +186,7 @@ async function listSubmissions() { settleSelection() } catch { // 失败了别把「落地后停在哪一头」留给下一次不相干的加载 - if (seq === requestSeq) pendingJump = null + if (seq === requestSeq) forgetJump() } finally { if (seq === requestSeq) loading.value = false } @@ -288,106 +275,26 @@ watch( // ==================== 选中与翻阅 ==================== -const selectedId = ref("") -const selectedRow = computed(() => rows.value.find((row) => row.id === selectedId.value) ?? null) - -/** 方向键只在能看的行之间走:跳到一条看不了的上面只会看到一把锁 */ -const navigable = computed(() => rows.value.filter((row) => row.showLink)) -const position = computed(() => { - const index = navigable.value.findIndex((row) => row.id === selectedId.value) - return index === -1 ? null : { index: index + 1, count: navigable.value.length } -}) - -// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选 -let pendingJump: "first" | "last" | null = null - -function settleSelection() { - const jump = pendingJump - pendingJump = null - const list = navigable.value - if (jump) { - const row = jump === "first" ? list[0] : list[list.length - 1] - if (row) { - select(row.id) - return - } - message.info("这一页没有可以查看的代码") - } - // 地址里带着 ?id= 的(手机上开着详情时刷新、别人发来的链接)就停在那一条 - const wanted = typeof route.query.id === "string" ? route.query.id : "" - if (wanted && rows.value.some((row) => row.id === wanted)) { - selectedId.value = wanted - return - } - // 这一页上没有那一条:详情开不出来,把 ?id= 摘掉 - if (paneId.value) router.replace({ query: queryWithoutId() }) - if (rows.value.some((row) => row.id === selectedId.value)) return - // 手机上没有右栏,不用预先选一条 - if (isMobile.value) { - selectedId.value = "" - return - } - selectedId.value = (list[0] ?? rows.value[0])?.id ?? "" -} - -function select(id: string) { - selectedId.value = id - nextTick(() => { - document - .querySelector(`[data-row-id="${CSS.escape(id)}"]`) - ?.scrollIntoView({ block: "nearest" }) +const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } = + useRowCursor({ + rows, + total, + query, + loading, + isMobile, + isDesktop, + keysBlocked: () => todayPanel.value || statsPanel.value, + wantedId: () => (typeof route.query.id === "string" ? route.query.id : ""), + dropWanted: () => { + if (paneId.value) router.replace({ query: queryWithoutId() }) + }, + onNothingToShow: () => message.info("这一页没有可以查看的代码"), }) -} - -const maxPage = computed(() => Math.max(1, Math.ceil(total.value / query.limit))) - -function move(step: 1 | -1) { - // 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来 - if (!navigable.value.length) return - // 上一次跨页还没回来:按住 ↓ 或者在页尾连按两下,不然会整页跳过 - if (loading.value || pendingJump) return - const all = rows.value - const start = all.findIndex((row) => row.id === selectedId.value) - for (let i = start + step; i >= 0 && i < all.length; i += step) { - if (all[i]!.showLink) { - select(all[i]!.id) - return - } - } - const page = query.page + step - if (page < 1 || page > maxPage.value) return - pendingJump = step > 0 ? "first" : "last" - query.page = page -} const [todayPanel, toggleTodayPanel] = useToggle(false) useBackClose(todayPanel, isMobile) const statsPanel = ref(false) -// 只用 ↑ ↓(用户定的,← → 不要) -onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => { - if (!isDesktop.value || todayPanel.value || statsPanel.value) return - // 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项) - if (e.defaultPrevented) return - const target = e.target as HTMLElement | null - if (target) { - // 「代码 / 流程图」「全部 / 我的」是单选框:点过之后焦点停在上面,浏览器默认会拿方向键 - // 切换选项 —— 老师想看下一条,结果模式被切了。单选框照常换条,下面的 preventDefault 挡掉切换 - const radio = target.tagName === "INPUT" && (target as HTMLInputElement).type === "radio" - // 焦点在输入框、下拉框、弹框里时不抢方向键 - if ( - (!radio && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) || - target.isContentEditable || - target.closest(".n-base-selection, .n-base-select-menu, .n-dropdown-menu, .n-modal") - ) { - return - } - } - if (!rows.value.length) return - e.preventDefault() - move(e.key === "ArrowUp" ? -1 : 1) -}) - // 手机上没有右栏:点一行从右边滑出整屏的详情,地址带上 ?id=(见 paneId) const mobilePane = computed({ get: () => !!paneId.value, @@ -447,64 +354,17 @@ watch(paneId, (id) => { // ==================== 实时更新 ==================== -/** - * 两件事,一个定时器: - * - 本页有「正在评分」的,3 秒拉一次,把结果原地换上; - * - 第一页每 15 秒看一眼有没有新交的。**新的不直接插进来** —— 老师正看着第 5 行, - * 上面突然塞进 3 行,选中的那行就跑了。顶上给一条「有 N 条新提交」,点了才换。 - * 页面切到后台就停。 - */ -const newCount = ref(0) -const newCountCapped = computed(() => newCount.value >= query.limit) -let lastPoll = Date.now() -const visibility = useDocumentVisibility() - -function isPending(row: Row) { - if ("result" in row) { - return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging - } - return ( - row.status === FlowchartSubmissionStatus.PENDING || - row.status === FlowchartSubmissionStatus.PROCESSING - ) -} - -const hasPending = computed(() => rows.value.some(isPending)) - -useIntervalFn(async () => { - if (visibility.value !== "visible" || loading.value || !loaded.value) return - const interval = hasPending.value ? 3000 : 15000 - if (Date.now() - lastPoll < interval) return - if (!hasPending.value && query.page !== 1) return - lastPoll = Date.now() - const seq = requestSeq - const mode = flowMode.value - let res - try { - res = await fetchPage() - } catch { - return - } - // 这期间筛选变了、翻页了,这份结果就作废 - if (seq !== requestSeq || mode !== flowMode.value) return - const fresh = new Map(res.results.map((row) => [row.id, row])) - if (mode) { - flowcharts.value = flowcharts.value.map( - (row) => (fresh.get(row.id) as FlowchartSubmissionListItem | undefined) ?? row, - ) - } else { - submissions.value = submissions.value.map( - (row) => (fresh.get(row.id) as SubmissionListItem | undefined) ?? row, - ) - } - if (query.page === 1) { - const current = new Set(rows.value.map((row) => row.id)) - const newest = rows.value[0]?.createTime ?? "" - newCount.value = res.results.filter( - (row) => !current.has(row.id) && row.createTime > newest, - ).length - } -}, 1000) +const { newCount, newCountCapped, pollSoon } = useListLive({ + query, + flowMode, + rows, + submissions, + flowcharts, + loading, + loaded, + fetchPage, + requestSeq: () => requestSeq, +}) function showNew() { newCount.value = 0 @@ -576,50 +436,11 @@ const today = computed({ set: (value: boolean) => (query.today = value ? "1" : "0"), }) -/** 「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */ -const lesson = ref(null) -const lessonIds = computed( - () => lesson.value?.problems.map((problem) => problem.problemDisplayId).join(",") ?? "", -) -const lessonActive = computed( - () => - !!lesson.value?.className && - !!lesson.value.problems.length && - query.className === lesson.value.className && - query.problem === lessonIds.value && - query.today === "1", -) -const lessonLoading = ref(false) - -async function toggleLesson() { - if (lessonActive.value) { - query.problem = "" - query.today = "0" - // 「这节课」顺带筛上的语言一起放掉;老师自己另选的别动 - if (lesson.value?.language && query.language === lesson.value.language) query.language = "" - return - } - lessonLoading.value = true - try { - const res = await getClassLesson(query.className || undefined) - if (!res.className) { - message.info("猜不出是哪个班在上课,先在班级里选一个") - return - } - query.className = res.className - if (!res.problems.length) { - message.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`) - return - } - lesson.value = res - query.problem = lessonIds.value - query.today = "1" - // 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动) - if (res.language && !flowMode.value) query.language = res.language - } finally { - lessonLoading.value = false - } -} +const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({ + query, + flowMode, + info: (text) => message.info(text), +}) const activeFilters = computed( () => @@ -652,65 +473,13 @@ function filterUser(username: string) { query.username = username } -/** - * 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图 - * 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的 - */ -const statsQuery = reactive({ - tab: "code", - className: "", - username: "", - problem: "", - period: DEFAULT_PERIOD as string, - from: "", - to: "", +const { statsQuery, openStatistics, openStatisticsPage, listFromStatistics } = useStatsLink({ + query, + flowMode, + statsPanel, + exactFor, }) -/** - * 老师的「统计」:带着这张列表现在的班级 / 学生 / 题号 / 代码或流程图打开。原来旁边还有一颗 - * 「今日统计」,和「数据统计」名字分不清(用户说的),老师那一版并进来了 —— 什么都没筛时 - * 打开就是「一行一节课」,选「今天」就是原来今日统计看的东西 - */ -function openStatistics() { - Object.assign(statsQuery, { - tab: flowMode.value ? "flow" : "code", - className: query.className, - username: query.username, - problem: query.problem, - ...(query.today === "1" ? { period: "today" } : {}), - }) - statsPanel.value = true -} - -/** 手机上的「统计」:同样的条件,带去统计页 */ -function openStatisticsPage() { - router.push({ - name: "statistics", - query: { - tab: flowMode.value ? "flow" : "code", - ...(query.className ? { className: query.className } : {}), - ...(query.username ? { username: query.username } : {}), - ...(query.problem ? { problem: query.problem } : {}), - ...(query.today === "1" ? { period: "today" } : {}), - }, - }) -} - -/** 统计里的「在提交列表里看」:直接改背后这张列表的筛选,关掉弹框 */ -function listFromStatistics(next: Record) { - statsPanel.value = false - exactFor.value = "" - Object.assign(query, { - username: next.username ?? "", - className: next.className ?? "", - result: "", - myself: "0", - problem: next.problem ?? "", - language: next.language === "Flowchart" ? "Flowchart" : flowMode.value ? "" : query.language, - today: next.today === "1" ? "1" : "0", - }) -} - /** 右栏「只看这个班」:从看一个人换成看他整个班 */ function filterClass(name: string) { exactFor.value = "" @@ -747,7 +516,7 @@ async function rejudge(id: string) { message.success("已重新判题,结果出来会自己更新") const row = submissions.value.find((item) => item.id === id) if (row) row.result = SubmissionStatus.pending - lastPoll = 0 + pollSoon() } async function retryFlowchart(id: string) { @@ -766,7 +535,7 @@ async function retryFlowchart(id: string) { message.success("已重新评分,结果出来会自己更新") const row = flowcharts.value.find((item) => item.id === id) if (row) row.status = FlowchartSubmissionStatus.PENDING - lastPoll = 0 + pollSoon() } /**