Files
OJ2/apps/web/src/oj/submission/list.vue
T
xuyueandClaude Opus 5.5 5c997b6276 fix(提交列表): 窗口窄时筛选栏的学生、题号框被压扁,「统计」被挤出屏幕
排不下就折成两行,各个框保持原宽;1280 以上照旧一行、高度不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:38:53 +08:00

1648 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import { errorCode } from "utils/api"
import {
adminRejudge,
getFlowchartSubmissions,
getSubmissions,
getTodaySubmissionCount,
retryFlowchartSubmission,
} from "oj/api"
import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
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 { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types"
import { FlowchartSubmissionStatus } from "utils/types"
import FlowchartState from "./components/FlowchartState.vue"
import StatusPill from "./components/StatusPill.vue"
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"
/**
* 提交列表(设计稿「提交列表重设计 · 定稿」):左边列表、右边常驻选中那条的代码。
*
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
* 完成情况在课堂看板和「统计」里,这页不重复造一张表。
*/
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
// 统计带着 chart.js,只有老师点开才拉
const StatisticsView = defineAsyncComponent(
() => import("oj/statistics/components/StatisticsView.vue"),
)
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const configStore = useConfigStore()
const contestStore = useContestStore()
const message = useMessage()
const theme = useThemeVars()
const tone = useTone()
const { isMobile, isDesktop } = useBreakpoints()
/**
* 左栏收窄(底栏最左那个按钮):屏窄、或者浏览器放大了一档时右边的代码看不全,
* 收窄后每条排成手机上那样的两行,什么都不丢。记在本机,下次打开还是收着的
*/
const narrowList = useStorage("oj2:submission-list-narrow", false)
const twoLine = computed(() => isMobile.value || narrowList.value)
const inContest = computed(() => route.name === "contest submissions")
/**
* 手机上点开的那一条,记在地址的 `?id=` 上:详情是从右边滑出的整屏,学生下意识按返回 ——
* 返回键归路由管,退一步就是关掉详情、回到列表;刷新、转发链接也还停在这一条。
* 列表在底下不卸载,滚到哪、上一条 / 下一条都还在
*/
const paneId = computed(() =>
isMobile.value && typeof route.query.id === "string" ? route.query.id : "",
)
/** 从详情里点了「只看他」这类筛选:这一下要连 ?id= 一起换掉,见 leavePane() */
let leavingPane = false
const teacher = computed(() => userStore.isTeacherOrAbove)
// 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好
const { query, clearQuery } = usePagination<Omit<SubmissionQuery, "page" | "limit">>(
{
username: useRouteQuery("username", "").value,
className: useRouteQuery("className", "").value,
result: useRouteQuery("result", "").value,
myself: useRouteQuery("myself", "0").value,
problem: useRouteQuery("problem", "").value,
language: useRouteQuery("language", "").value,
// 原来写死成 "0",从别处带着 today=1 进来也不认
today: useRouteQuery("today", "0").value,
},
{
defaultLimit: 30,
// 手机上开着详情:翻页、换条件时地址里的 ?id= 带着走,也不多压历史(返回键是关详情)
extraQuery: (): Record<string, string> =>
paneId.value && !leavingPane ? { id: paneId.value } : {},
replace: () => !!paneId.value && !leavingPane,
},
)
const flowMode = computed(() => !inContest.value && query.language === "Flowchart")
/**
* 用户名按整名匹配的那一个名字。默认是「包含」,而学号互相包含是常态(ks24a1 会混进
* ks24a10–ks24a19)。两处会设它:协作条「看他交过什么」带进来的 `exactUsername=1`,
* 和右栏的「只看他」。老师在输入框里改了名字就回到包含匹配
*/
const exactFor = ref(useRouteQuery<string>("exactUsername", "").value === "1" ? query.username : "")
const submissions = ref<SubmissionListItem[]>([])
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
const total = ref(0)
const todayCount = ref(0)
/** 题号框里查不到的题号(打错了)。一次填好几道时打错的那道会被忽略,得告诉老师 */
const unknownProblems = ref<string[]>([])
const loading = ref(false)
const loaded = ref(false)
/** 转圈延迟 300ms 才出现:翻页、切筛选大多一百毫秒内回来,直接换数据比闪一下转圈稳 */
const showLoading = ref(false)
let loadingTimer: ReturnType<typeof setTimeout> | undefined
watch(loading, (value) => {
clearTimeout(loadingTimer)
if (!value) {
showLoading.value = false
return
}
loadingTimer = setTimeout(() => (showLoading.value = true), 300)
})
onUnmounted(() => clearTimeout(loadingTimer))
const rows = computed<Row[]>(() => (flowMode.value ? flowcharts.value : submissions.value))
function fetchPage() {
const offset = query.limit * (query.page - 1)
if (flowMode.value) {
return getFlowchartSubmissions({
username: query.username,
className: query.className,
problemDisplayId: query.problem,
myself: query.myself,
offset,
limit: query.limit,
today: query.today,
grade: query.result,
...(exactFor.value && query.username === exactFor.value
? { exactUsername: "1" as const }
: {}),
})
}
return getSubmissions({
username: query.username,
className: inContest.value ? undefined : query.className,
result: query.result,
myself: query.myself,
offset,
limit: query.limit,
page: query.page,
problemDisplayId: query.problem,
contestId: inContest.value ? String(route.params.contestID) : "",
language: inContest.value ? "" : (query.language as LANGUAGE | ""),
today: inContest.value ? undefined : query.today,
...(exactFor.value && query.username === exactFor.value ? { exactUsername: "1" as const } : {}),
})
}
// 并发的几次请求只认最后一次:连着切筛选时,先发的晚回来会把新结果盖掉
let requestSeq = 0
async function listSubmissions() {
if (query.page < 1) query.page = 1
const seq = ++requestSeq
const mode = flowMode.value
loading.value = true
try {
const res = await fetchPage()
if (seq !== requestSeq) return
if (mode) flowcharts.value = res.results as FlowchartSubmissionListItem[]
else submissions.value = asSeenByStudent(res.results as SubmissionListItem[])
total.value = res.total
unknownProblems.value = res.unknownProblems
newCount.value = 0
loaded.value = true
// 翻了页、换了条件就回到列表顶上(往回翻到上一页末尾的,settleSelection 会滚到那一条)
const scroller = document.querySelector(".rows")
if (scroller) scroller.scrollTop = 0
// 手机上整页滚,滚的是外层 n-layout 的容器:翻到下一页原来停在页尾
if (isMobile.value) pageScroller()?.scrollTo({ top: 0 })
settleSelection()
} catch {
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
if (seq === requestSeq) forgetJump()
} finally {
if (seq === requestSeq) loading.value = false
}
}
const pageRoot = useTemplateRef<HTMLElement>("pageRoot")
/** 往上找真正在滚的那层(同自学页:n-layout-content 也带 scroll-container 类却不滚) */
function pageScroller(): HTMLElement | null {
for (let el = pageRoot.value?.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY
if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
return el
}
}
return null
}
async function getTodayCount() {
todayCount.value = await getTodaySubmissionCount(flowMode.value ? "Flowchart" : undefined)
}
onMounted(() => {
listSubmissions()
// 「今日统计 N」只给学生:老师那一版并进了「统计」
if (route.name === "submissions" && !teacher.value) getTodayCount()
})
watchDebounced(() => [query.username, query.problem], listSubmissions, {
debounce: 500,
maxWait: 1000,
})
watch(
() => [
query.page,
query.limit,
query.myself,
query.result,
query.language,
query.today,
query.className,
],
listSubmissions,
)
// 代码和流程图的「结果」不是一套(判题状态 / 等级),切过去要清掉
watch(
() => query.language,
(value, old) => {
if ((value === "Flowchart") !== (old === "Flowchart")) query.result = ""
if (route.name === "submissions") getTodayCount()
},
)
// 登录态一变,能看哪些代码就变了(showLink 是后端逐行算的)。掉了登录还停在流程图上的话
// 退回代码:流程图列表要登录才能看,而没登录时那个切换按钮不显示,会卡在一片报错里出不去
watch(
() => userStore.isAuthed,
(authed) => {
if (!authed && query.language === "Flowchart") query.language = ""
else listSubmissions()
},
)
/**
* 地址栏整个换掉时(顶栏「提交」「我的提交」、浏览器后退),地址里没写的筛选要回到默认值。
* usePagination 只同步地址里**有**的键 —— 在 `?myself=1` 上点顶栏「提交」,地址已经变成
* `/submission`,页面却还停在「我的」
*/
const QUERY_DEFAULTS: Record<string, string> = {
username: "",
className: "",
result: "",
myself: "0",
problem: "",
language: "",
today: "0",
}
watch(
() => route.query,
(next) => {
const writable = query as unknown as Record<string, string>
for (const [key, value] of Object.entries(QUERY_DEFAULTS)) {
if (next[key] === undefined && writable[key] !== value) writable[key] = value
}
},
)
// ==================== 选中与翻阅 ====================
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 [todayPanel, toggleTodayPanel] = useToggle(false)
useBackClose(todayPanel, isMobile)
const statsPanel = ref(false)
// 手机上没有右栏:点一行从右边滑出整屏的详情,地址带上 ?id=(见 paneId)
const mobilePane = computed({
get: () => !!paneId.value,
set: (open) => {
if (!open) closePane()
},
})
function queryWithoutId() {
const { id: _, ...rest } = route.query
return rest
}
function rowClicked(row: Row) {
select(row.id)
if (isMobile.value) router.push({ query: { ...route.query, id: row.id } })
}
/**
* 点 × 关:从列表点开的就退一步(和按返回键一样,不留一条多余的历史);
* 直接带着 ?id= 进来的(刷新过、别人发的链接)后面没有列表可退,把地址换掉
*/
function closePane() {
const back = history.state?.back
if (typeof back === "string" && router.resolve(back).name === route.name) router.back()
else router.replace({ query: queryWithoutId() })
}
/**
* 详情里点「只看他」「只看这道题」:先关详情(退回列表那一条),再改筛选。
* 反过来的话筛选压的那条历史在详情后面,按返回又回到详情。
* 等路由落地、再等分页把地址同步回 query 之后才改,不然改的值会被同步盖掉
*/
function leavePane(next: () => void) {
if (!paneId.value) return next()
leavingPane = true
const off = router.afterEach(() => {
off()
nextTick(() => {
leavingPane = false
next()
})
})
closePane()
}
// 上一条 / 下一条换了人:地址里的 ?id= 跟着换(replace,返回键照样是关详情)
watch(selectedId, (id) => {
if (paneId.value && id && id !== paneId.value) {
router.replace({ query: { ...route.query, id } })
}
})
// 前进后退到另一条(或者刷新后列表回来之前地址就有了):选中跟着地址走
watch(paneId, (id) => {
if (id && rows.value.some((row) => row.id === id)) selectedId.value = id
})
// ==================== 实时更新 ====================
const { newCount, newCountCapped, pollSoon } = useListLive({
query,
flowMode,
rows,
submissions,
flowcharts,
loading,
loaded,
fetchPage,
requestSeq: () => requestSeq,
})
function showNew() {
newCount.value = 0
if (query.page !== 1) query.page = 1
else listSubmissions()
}
// ==================== 筛选 ====================
const resultOptions = [
{ label: "全部结果", value: "" },
{ label: "答案正确", value: String(SubmissionStatus.accepted) },
{ label: "语法未通过", value: String(SubmissionStatus.ast_check_failed) },
{ label: "答案错误", value: String(SubmissionStatus.wrong_answer) },
{ label: "编译失败", value: String(SubmissionStatus.compile_error) },
{ label: "运行时错误", value: String(SubmissionStatus.runtime_error) },
{ label: "运行超时", value: String(SubmissionStatus.cpu_time_limit_exceeded) },
]
const gradeOptions = [
{ label: "全部等级", value: "" },
{ label: "S 级", value: "S" },
{ label: "A 级", value: "A" },
{ label: "B 级", value: "B" },
{ label: "C 级", value: "C" },
]
// 原来没有 SQL:3 月以来有 91 条 SQL 提交、9 道题开了 SQL,想筛只能靠翻
const languageOptions = [
{ label: "全部语言", value: "" },
{ label: "Python", value: "Python" },
{ label: "C语言", value: "C" },
{ label: "C++", value: "C++" },
{ label: "SQL", value: "SQL" },
]
const classOptions = computed(() =>
classSelectOptions(configStore.config?.classList ?? [], query.className),
)
const contestProblemOptions = computed(() => [
{ label: "全部题目", value: "" },
...contestStore.problems.map((problem) => ({
label: `${problem._id} ${problem.title}`,
value: problem._id,
})),
])
const mode = computed({
get: () => (flowMode.value ? "flow" : "code"),
set: (value: string) => (query.language = value === "flow" ? "Flowchart" : ""),
})
const mine = computed(() => query.myself === "1")
const scope = computed({
get: () => (mine.value ? "mine" : "all"),
set: (value: string) => {
// 「这节课」是看全班用的,切到「我的」就把它带的题号和「今天」一起放掉
if (value === "mine" && lessonActive.value) {
query.problem = ""
query.today = "0"
}
query.myself = value === "mine" ? "1" : "0"
},
})
const today = computed({
get: () => query.today === "1",
set: (value: boolean) => (query.today = value ? "1" : "0"),
})
const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
query,
flowMode,
info: (text) => message.info(text),
})
const activeFilters = computed(
() =>
[query.username, query.className, query.result, query.problem].filter(Boolean).length +
(query.today === "1" ? 1 : 0) +
(query.language && query.language !== "Flowchart" ? 1 : 0),
)
const mobileFilters = ref(false)
function clear() {
const keepMode = query.language === "Flowchart" ? "Flowchart" : ""
exactFor.value = ""
clearQuery()
query.myself = "0"
query.today = "0"
query.language = keepMode
}
/** 现在是不是按整名只看一个人(输入框换成名字标签的时候) */
const exactPerson = computed(() => !!exactFor.value && query.username === exactFor.value)
function clearPerson() {
exactFor.value = ""
query.username = ""
}
function filterUser(username: string) {
exactFor.value = username
query.myself = "0"
query.username = username
}
const { statsQuery, openStatistics, openStatisticsPage, listFromStatistics } = useStatsLink({
query,
flowMode,
statsPanel,
exactFor,
})
/** 右栏「只看这个班」:从看一个人换成看他整个班 */
function filterClass(name: string) {
exactFor.value = ""
query.username = ""
query.myself = "0"
query.className = name
}
function filterProblem(displayId: string) {
query.problem = displayId
}
// ==================== 行上的动作 ====================
function openProblem(row: Row) {
if (inContest.value) {
const path = router.resolve({
name: "contest problem",
params: { contestID: route.params.contestID, problemID: row.problemDisplayId },
})
window.open(path.href, "_blank")
} else {
window.open("/problem/" + row.problemDisplayId, "_blank")
}
}
async function rejudge(id: string) {
try {
await adminRejudge(id)
} catch {
message.error("重新判题没成功")
return
}
message.success("已重新判题,结果出来会自己更新")
const row = submissions.value.find((item) => item.id === id)
if (row) row.result = SubmissionStatus.pending
pollSoon()
}
async function retryFlowchart(id: string) {
// 按错误码分支而不是 match 文案(见 utils/api.ts 的约定)——后端文案是英文的
const retryTips: Record<string, string> = {
"retry-not-allowed": "这条还在评分中,等出了结果再重新评分",
"too-many-submissions": "操作太频繁了,缓一下再试",
"flowchart-not-found": "提交不存在,或者没有权限",
}
try {
await retryFlowchartSubmission(id)
} catch (err) {
message.error(retryTips[errorCode(err) ?? ""] ?? "重新评分失败")
return
}
message.success("已重新评分,结果出来会自己更新")
const row = flowcharts.value.find((item) => item.id === id)
if (row) row.status = FlowchartSubmissionStatus.PENDING
pollSoon()
}
/**
* 列表上写语言,不写「通过 1/1」:学生把那个数当成了交了几次。
* 差几个测试点在右栏状态下面有整句「通过 x/y 个测试点」
*/
function languageText(row: SubmissionListItem) {
return LANGUAGE_SHOW_VALUE[row.language] ?? row.language
}
/** 学生看别人的那几行:名字淡一档、带一把锁(自己的看不了是题单那道闸,另说) */
/**
* 演示模式(超管把界面装成学生投屏):后端认的还是超管,每一行都给了 showLink。
* 学生是看不到别人代码的,投屏时也不该露出来 —— 别人的行一律当成看不到
*/
function asSeenByStudent(rows: SubmissionListItem[]) {
if (!userStore.demoMode) return rows
const me = userStore.user?.id
return rows.map((row) => (row.userId === me ? row : { ...row, showLink: false }))
}
function othersRow(row: SubmissionListItem) {
return !row.showLink && row.userId !== userStore.user?.id
}
/**
* 按天插一条分隔行,时间那一列只写钟点。原来每行都写「9月28日 23:59」,
* 左栏 560 宽里题目名就只剩三个字。
*/
function dayKey(time: string) {
return parseTime(time, "YYYY-MM-DD")
}
function dayBreak(index: number) {
const list = rows.value
const key = dayKey(list[index]!.createTime)
if (index > 0 && dayKey(list[index - 1]!.createTime) === key) return ""
return submissionDayText(list[index]!.createTime)
}
</script>
<template>
<div
ref="pageRoot"
class="page"
:class="{ contest: inContest, mobile: isMobile, 'narrow-list': isDesktop && narrowList }"
>
<!-- 筛选栏 -->
<div v-if="isDesktop" class="filters">
<!-- 流程图列表要登录才能看(GET /flowcharts 是 requireAuth),没登录就不给这个切换 -->
<template v-if="!inContest && userStore.isAuthed">
<n-radio-group v-model:value="mode">
<n-radio-button value="code">代码</n-radio-button>
<n-radio-button value="flow">流程图</n-radio-button>
</n-radio-group>
</template>
<!-- 老师也有:从个人菜单「我的提交」进来的,原来只能靠「清空」退出去 -->
<n-radio-group v-if="userStore.isAuthed" v-model:value="scope">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="mine">我的</n-radio-button>
</n-radio-group>
<span v-if="userStore.isAuthed" class="vsep"></span>
<!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 -->
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、看板跳过来)就摆一个能点掉的标签,
不然一直被一个看不见的条件筛着 -->
<button
v-if="!teacher && query.className"
class="chip on"
title="点一下去掉班级筛选"
@click="query.className = ''"
>
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
</button>
<template v-if="teacher && !inContest && !mine">
<button
class="lesson"
:class="{ on: lessonActive }"
:disabled="lessonLoading"
title="筛到正在上课的班、这节课的题、今天"
@click="toggleLesson"
>
<Icon v-if="lessonActive" icon="ph:check-bold" :width="12" />
<b>这节课</b>
<template v-if="lessonActive && lesson?.className">
<span>
{{ classLabel(lesson.className) }} · {{ lesson.problems.length }} 道题 · 今天
</span>
<Icon icon="ph:x-bold" :width="12" class="lesson-x" />
</template>
</button>
<n-select
v-if="!lessonActive"
:value="query.className || null"
class="w-class"
:options="classOptions"
placeholder="全部班级"
clearable
filterable
@update:value="(v: string | null) => (query.className = v ?? '')"
/>
</template>
<!-- 按整名只看一个人(「只看他」、协作条、看板点进来):用户名有八九个字,塞进 108px 的
输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 -->
<button
v-if="!mine && exactPerson"
class="chip on person"
:title="`只看 ${query.username}(点一下取消)`"
@click="clearPerson"
>
<Icon icon="ph:user-bold" :width="12" />
<UserName :username="query.username" class="person-name" />
<Icon icon="ph:x-bold" :width="12" />
</button>
<n-input
v-else-if="!mine"
v-model:value="query.username"
class="w-user"
clearable
:placeholder="teacher ? '学生' : '用户'"
>
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<n-select
v-if="inContest"
v-model:value="query.problem"
class="w-problem-select"
:options="contestProblemOptions"
/>
<n-input
v-else-if="!lessonActive"
v-model:value="query.problem"
class="w-problem"
clearable
placeholder="题号"
:status="unknownProblems.length ? 'warning' : undefined"
>
<template #prefix><Icon icon="ph:hash" /></template>
</n-input>
<n-select
v-model:value="query.result"
class="w-result"
:options="flowMode ? gradeOptions : resultOptions"
/>
<n-select
v-if="!inContest && !flowMode"
v-model:value="query.language"
class="w-lang"
:options="languageOptions"
/>
<button
v-if="!inContest && !lessonActive"
class="chip"
:class="{ on: today }"
@click="today = !today"
>
<Icon v-if="today" icon="ph:check-bold" :width="12" />今天
</button>
<n-button quaternary @click="clear">清空</n-button>
<div class="spacer"></div>
<!-- 老师只有「统计」(今日统计并进去了);学生看不到统计,仍是「今日统计」 -->
<n-button
v-if="teacher && route.name === 'submissions'"
quaternary
title="统计:这节课、今天,或者回头看以前的课"
@click="openStatistics()"
>
<template #icon><Icon icon="ph:chart-bar" /></template>
统计
</n-button>
<n-button
v-else-if="route.name === 'submissions' && !flowMode"
quaternary
:title="`今日统计:今天全站 ${todayCount} 条`"
@click="toggleTodayPanel(true)"
>
<template #icon><Icon icon="ph:chart-bar" /></template>
今日统计
<span v-if="todayCount" class="count">{{ todayCount }}</span>
</n-button>
</div>
<!-- 手机:只留全部/我的、统计,其余收进「筛选」 -->
<div v-else class="filters mobile-filters">
<n-radio-group v-if="userStore.isAuthed" v-model:value="scope">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="mine">我的</n-radio-button>
</n-radio-group>
<div class="spacer"></div>
<!-- 统计那张大表在弹框里排不开,手机上老师直接去统计页(页面形态有手机排版) -->
<n-button
v-if="teacher && route.name === 'submissions'"
quaternary
@click="openStatisticsPage"
>
<template #icon><Icon icon="ph:chart-bar" /></template>
统计
</n-button>
<n-button
v-else-if="route.name === 'submissions' && !flowMode"
quaternary
@click="toggleTodayPanel(true)"
>
<template #icon><Icon icon="ph:chart-bar" /></template>
今日统计
</n-button>
<n-button
:type="mobileFilters || activeFilters ? 'primary' : 'default'"
:secondary="!mobileFilters && !!activeFilters"
@click="mobileFilters = !mobileFilters"
>
<template #icon><Icon icon="ph:funnel-simple" /></template>
筛选<template v-if="activeFilters"> · {{ activeFilters }}</template>
</n-button>
</div>
<div v-if="isMobile && mobileFilters" class="mobile-panel">
<n-radio-group v-if="!inContest && userStore.isAuthed" v-model:value="mode">
<n-radio-button value="code">代码</n-radio-button>
<n-radio-button value="flow">流程图</n-radio-button>
</n-radio-group>
<button v-if="!teacher && query.className" class="chip on" @click="query.className = ''">
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
</button>
<template v-if="teacher && !inContest && !mine">
<button
class="lesson"
:class="{ on: lessonActive }"
:disabled="lessonLoading"
@click="toggleLesson"
>
<Icon v-if="lessonActive" icon="ph:check-bold" :width="12" />
<b>这节课</b>
<template v-if="lessonActive && lesson?.className">
<span class="lesson-text">
{{ classLabel(lesson.className) }} · {{ lesson.problems.length }} 道题 · 今天
</span>
<Icon icon="ph:x-bold" :width="12" class="lesson-x" />
</template>
</button>
<n-select
v-if="!lessonActive"
:value="query.className || null"
:options="classOptions"
placeholder="全部班级"
clearable
filterable
@update:value="(v: string | null) => (query.className = v ?? '')"
/>
</template>
<button v-if="!mine && exactPerson" class="chip on person" @click="clearPerson">
<Icon icon="ph:user-bold" :width="12" />
<UserName :username="query.username" class="person-name" />
<Icon icon="ph:x-bold" :width="12" />
</button>
<n-input
v-else-if="!mine"
v-model:value="query.username"
clearable
:placeholder="teacher ? '学生' : '用户'"
>
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<n-select v-if="inContest" v-model:value="query.problem" :options="contestProblemOptions" />
<n-input
v-else-if="!lessonActive"
v-model:value="query.problem"
clearable
placeholder="题号"
:status="unknownProblems.length ? 'warning' : undefined"
>
<template #prefix><Icon icon="ph:hash" /></template>
</n-input>
<div class="mobile-pair">
<n-select v-model:value="query.result" :options="flowMode ? gradeOptions : resultOptions" />
<n-select
v-if="!inContest && !flowMode"
v-model:value="query.language"
:options="languageOptions"
/>
</div>
<div class="mobile-panel-foot">
<button
v-if="!inContest && !lessonActive"
class="chip"
:class="{ on: today }"
@click="today = !today"
>
<Icon v-if="today" icon="ph:check-bold" :width="12" />今天
</button>
<n-button quaternary @click="clear">清空</n-button>
<div class="spacer"></div>
<n-button @click="mobileFilters = false">收起</n-button>
</div>
</div>
<div class="split">
<!-- 左栏:列表 -->
<section class="list">
<div v-if="unknownProblems.length" class="unknown-bar">
<Icon icon="ph:warning" :width="13" />
题号 {{ unknownProblems.join("、") }} 没有这道题,下面只有其余题号的提交
</div>
<button v-if="newCount" class="new-bar" @click="showNew">
<Icon icon="ph:arrow-up-bold" :width="13" />
有 {{ newCount }}{{ newCountCapped ? "+" : "" }} 条新提交,点这里显示
</button>
<div class="rows" :class="{ dim: showLoading }">
<template v-if="flowMode">
<template v-for="(row, index) in flowcharts" :key="row.id">
<div v-if="dayBreak(index)" class="day">{{ dayBreak(index) }}</div>
<div
class="row"
:class="{ on: row.id === selectedId && !isMobile, 'two-line': twoLine }"
:data-row-id="row.id"
@click="rowClicked(row)"
>
<template v-if="!twoLine">
<span class="c-time">{{ submissionClockText(row.createTime) }}</span>
<span class="c-user"><UserName :username="row.username" /></span>
<span class="c-problem" :title="`${row.problemDisplayId} ${row.problemTitle}`">
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
</span>
<span class="c-state">
<FlowchartState :status="row.status" :grade="row.aiGrade" />
</span>
</template>
<template v-else>
<div class="m-line">
<UserName :username="row.username" />
<div class="spacer"></div>
<FlowchartState :status="row.status" :grade="row.aiGrade" />
</div>
<div class="m-line sub">
<span class="m-time">{{ submissionClockText(row.createTime) }}</span>
<span class="title" :title="`${row.problemDisplayId} ${row.problemTitle}`">
{{ row.problemDisplayId }} {{ row.problemTitle }}
</span>
<div class="spacer"></div>
<Icon v-if="!row.showLink" icon="ph:lock-simple" :width="13" class="m-lock" />
</div>
</template>
</div>
</template>
</template>
<template v-else>
<template v-for="(row, index) in submissions" :key="row.id">
<div v-if="dayBreak(index)" class="day">{{ dayBreak(index) }}</div>
<div
class="row"
:class="{ on: row.id === selectedId && !isMobile, 'two-line': twoLine }"
:data-row-id="row.id"
@click="rowClicked(row)"
>
<template v-if="!twoLine">
<span class="c-time">{{ submissionClockText(row.createTime) }}</span>
<span class="c-user">
<UserName :username="row.username" :muted="othersRow(row)" />
</span>
<span class="c-problem" :title="`${row.problemDisplayId} ${row.problemTitle}`">
<span class="title">
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
</span>
<span
v-if="row.problemSet"
class="ps"
:title="'从题单「' + row.problemSet.title + '」交的'"
>
题单
</span>
</span>
<span class="c-lang">{{ languageText(row) }}</span>
<span class="c-state"><StatusPill :result="row.result" /></span>
<span v-if="!teacher" class="c-lock">
<!-- title 放在外层 span:挂在 svg 上 Chrome 不出提示 -->
<span
v-if="!row.showLink"
class="lock-hint"
:title="
othersRow(row)
? '别人的代码看不到'
: '题单布置期内先藏着:在题单里做对,或者布置期结束就能看'
"
>
<Icon icon="ph:lock-simple" :width="13" />
</span>
</span>
</template>
<template v-else>
<div class="m-line">
<UserName :username="row.username" :muted="othersRow(row)" />
<div class="spacer"></div>
<StatusPill :result="row.result" />
</div>
<div class="m-line sub">
<span class="m-time">{{ submissionClockText(row.createTime) }}</span>
<span class="title" :title="`${row.problemDisplayId} ${row.problemTitle}`">
{{ row.problemDisplayId }} {{ row.problemTitle }}
</span>
<span v-if="row.problemSet" class="ps">题单</span>
<div class="spacer"></div>
<span class="m-lang">{{ languageText(row) }}</span>
<Icon
v-if="!teacher && !row.showLink"
icon="ph:lock-simple"
:width="13"
class="m-lock"
/>
</div>
</template>
</div>
</template>
</template>
<div v-if="loaded && !rows.length" class="empty">
<span class="empty-title">
{{
lessonActive
? "这节课的题还没有人交"
: mine && activeFilters === 0
? "你还没有交过"
: "没有符合条件的提交"
}}
</span>
<n-button v-if="activeFilters" size="small" @click="clear">清空筛选</n-button>
</div>
<div v-if="!loaded" class="empty"><n-spin size="small" /></div>
</div>
<div class="list-foot">
<n-button
v-if="isDesktop"
size="small"
quaternary
:title="narrowList ? '列表放宽' : '列表收窄,右边的代码留宽一点'"
:aria-label="narrowList ? '列表放宽' : '列表收窄'"
@click="narrowList = !narrowList"
>
<template #icon>
<Icon :icon="narrowList ? 'ph:arrow-line-right' : 'ph:arrow-line-left'" />
</template>
</n-button>
<span v-if="total && !(isDesktop && narrowList)" class="total">共 {{ total }} 条</span>
<div class="spacer"></div>
<Pagination
class="pager"
:total="total"
:simple="isDesktop && narrowList"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</div>
</section>
<!-- 右栏:选中那一条 -->
<template v-if="isDesktop && selectedRow">
<FlowchartPane
v-if="flowMode"
:row="selectedRow as FlowchartSubmissionListItem"
:teacher="teacher"
:position="position"
@filter-user="filterUser"
@filter-problem="filterProblem"
@open-problem="openProblem"
@retry="retryFlowchart"
/>
<SubmissionPane
v-else
:row="selectedRow as SubmissionListItem"
:teacher="teacher"
:class-name="query.className"
:contest="inContest"
:position="position"
@filter-user="filterUser"
@filter-class="filterClass"
@clear-class="query.className = ''"
@filter-problem="filterProblem"
@open-problem="openProblem"
@rejudge="rejudge"
/>
</template>
<section v-else-if="isDesktop" class="pane-empty">
<span v-if="loaded && rows.length">点左边一行看代码</span>
</section>
</div>
</div>
<n-drawer v-if="isMobile" v-model:show="mobilePane" width="100%" placement="right">
<n-drawer-content
closable
title="提交详情"
:body-content-style="{ padding: 0, height: '100%' }"
>
<div v-if="selectedRow" class="mobile-pane">
<FlowchartPane
v-if="flowMode"
:row="selectedRow as FlowchartSubmissionListItem"
:teacher="teacher"
:position="null"
narrow
@filter-user="(name: string) => leavePane(() => filterUser(name))"
@filter-problem="(id: string) => leavePane(() => filterProblem(id))"
@open-problem="openProblem"
@retry="retryFlowchart"
/>
<SubmissionPane
v-else
:row="selectedRow as SubmissionListItem"
:teacher="teacher"
:class-name="query.className"
:contest="inContest"
:position="null"
narrow
@filter-user="(name: string) => leavePane(() => filterUser(name))"
@filter-class="(name: string) => leavePane(() => filterClass(name))"
@clear-class="() => leavePane(() => (query.className = ''))"
@filter-problem="(id: string) => leavePane(() => filterProblem(id))"
@open-problem="openProblem"
@rejudge="rejudge"
/>
</div>
<!-- 手机没有方向键:上一条 / 下一条换成按钮,走的是同一个 move() -->
<template #footer>
<n-flex justify="space-between" style="width: 100%">
<n-button
:disabled="!position || (position.index <= 1 && query.page <= 1)"
@click="move(-1)"
>
上一条
</n-button>
<span class="mobile-pos">{{
position ? `${position.index} / ${position.count}` : ""
}}</span>
<n-button
:disabled="!position || (position.index >= position.count && query.page >= maxPage)"
@click="move(1)"
>
下一条
</n-button>
</n-flex>
</template>
</n-drawer-content>
</n-drawer>
<!-- 标题栏在组件里:要写「截至几点」「判题中几条」,只有拉到数之后才知道 -->
<n-modal v-model:show="todayPanel">
<TodayStatistics
@close="todayPanel = false"
@loaded="(total: number) => (todayCount = total)"
@open-problem="(id: string) => openProblem({ problemDisplayId: id } as Row)"
/>
</n-modal>
<!--
包一层普通 div:n-modal 没有 preset 时把 class 和 ref 挂到直接子节点上,子节点是异步组件的话
里面下拉框的弹层插不进去(HierarchyRequestError,时间段、班级下拉点了没反应)
-->
<n-modal v-model:show="statsPanel">
<div>
<StatisticsView
v-model:tab="statsQuery.tab"
v-model:class-name="statsQuery.className"
v-model:username="statsQuery.username"
v-model:problem="statsQuery.problem"
v-model:period="statsQuery.period"
v-model:from="statsQuery.from"
v-model:to="statsQuery.to"
modal
@close="statsPanel = false"
@list="listFromStatistics"
/>
</div>
</n-modal>
</template>
<style scoped>
/* 铺满顶栏以下:和题目页一样用负边距吃掉外层 16px 的内边距(顶栏 56) */
.page {
margin: -16px;
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
background: v-bind("theme.cardColor");
}
/* 比赛里套在比赛页头下面,不铺满,给个框 */
.page.contest {
margin: 0;
height: calc(100vh - 150px);
min-height: 480px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
overflow: hidden;
}
/* 手机上整页自然滚动,不锁高度 */
.page.mobile {
height: auto;
}
.mobile .split {
flex: none;
}
.filters {
min-height: 60px;
flex: none;
box-sizing: border-box;
padding: 12px var(--oj-pad-x);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
white-space: nowrap;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
/* 窗口窄(或浏览器放大)排不下时折成两行,各个框保持原宽,不再被压扁、「统计」不再被挤出去 */
.filters:not(.mobile-filters) > * {
flex-shrink: 0;
}
.mobile-filters {
padding: 0 14px;
}
.mobile-filters {
height: 56px;
min-height: 0;
flex-wrap: nowrap;
gap: 4px;
}
.mobile-panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 14px 14px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.mobile-panel > .n-radio-group,
.mobile-panel > .chip {
align-self: flex-start;
}
.mobile-panel .lesson {
max-width: 100%;
min-width: 0;
align-self: flex-start;
}
.lesson-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-panel .chip.person {
max-width: 100%;
align-self: flex-start;
}
/* 结果、语言两个下拉并排:各占一半 */
.mobile-pair {
display: flex;
gap: 8px;
}
.mobile-pair > * {
flex: 1 1 0;
min-width: 0;
}
.mobile-panel-foot {
display: flex;
align-items: center;
gap: 8px;
}
.vsep {
width: 1px;
height: 20px;
margin: 0 4px;
background: v-bind("theme.dividerColor");
}
/* 最长的是「26计算机11班」:132px 时只剩两像素余量,字体稍宽的机器上就出省略号 */
.w-class {
width: 152px;
}
.w-user {
width: 120px;
}
.w-problem {
width: 100px;
}
.w-problem-select {
width: 160px;
}
.w-result {
width: 128px;
}
.w-lang {
width: 104px;
}
/* 机房 1280 的屏上老师那排只剩几像素:学生、语言两个框各收一点,别让班级框被挤扁;
控件放大到 34 高以后再把两头留白和标签的字收回一点 */
@media (max-width: 1365px) {
.filters {
padding-left: 16px;
padding-right: 16px;
}
.w-user {
width: 108px;
}
.w-lang {
width: 96px;
}
.chip,
.lesson {
font-size: 13px;
}
}
.spacer {
flex: 1 1 0;
}
.count {
margin-left: 4px;
color: v-bind("theme.textColor3");
}
.chip,
.lesson {
height: var(--oj-ctrl-h);
padding: 0 14px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 17px;
background: transparent;
font: inherit;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor2");
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
flex: none;
}
.chip.on,
.lesson.on {
border-color: v-bind("theme.primaryColor");
background: v-bind("tone('success').background");
color: v-bind("tone('success').color");
}
.chip.on {
font-weight: 600;
}
.chip.person {
max-width: 180px;
padding: 0 10px;
}
.chip.person .person-name {
font-size: var(--oj-fs-sec);
}
.lesson b {
font-weight: 600;
}
.lesson-x {
margin-left: 2px;
opacity: 0.7;
}
.split {
flex: 1 1 0;
min-height: 0;
display: flex;
}
/* 1920 的屏上和题目列表一样限宽 1600 居中,代码区不至于拉到两千像素宽 */
.page:not(.contest):not(.mobile) > .filters,
.page:not(.contest):not(.mobile) > .split {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
/* 列表比代码区窄一点(46 : 54,用户看过定的):原来列表固定 560、代码占满,右边空一大片;
改成 56% 又嫌列表太宽。46% 在 1280 的屏上题目列约 215px,九成题目名放得下,长的看 title。
大屏上列表最宽 900 */
.list {
flex: 0 0 46%;
max-width: 900px;
box-sizing: border-box;
border-right: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
min-height: 0;
}
/* 收窄:两行的排法,名字 + 结果一行、时间 + 题目 + 语言一行,题名还能露出十来个字 */
.narrow-list .list {
flex: 0 0 320px;
}
.mobile .list {
flex: 1 1 auto;
max-width: none;
width: 100%;
border-right: 0;
}
.new-bar {
height: 36px;
flex: none;
border: 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("tone('success').background");
font: inherit;
font-size: var(--oj-fs-sec);
color: v-bind("tone('success').color");
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
}
.unknown-bar {
height: 36px;
flex: none;
box-sizing: border-box;
padding: 0 var(--oj-pad-x);
display: flex;
align-items: center;
gap: 6px;
font-size: var(--oj-fs-sec);
color: v-bind("tone('warning').color");
background: v-bind("tone('warning').background");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.rows {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
transition: opacity 0.15s;
}
/* 手机上整页滚:列表按内容撑高(flex: 1 1 0 在不定高的容器里会算成 0) */
.mobile .rows {
flex: none;
overflow: visible;
}
.rows.dim {
opacity: 0.55;
}
.row {
height: var(--oj-row-h);
box-sizing: border-box;
padding: 0 16px 0 var(--oj-pad-x);
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
cursor: pointer;
font-size: var(--oj-fs-body);
}
/* 往上翻时选中那一行别被吸顶的日期行挡住 */
.row {
scroll-margin-top: 34px;
}
.row:hover {
background: v-bind("theme.hoverColor");
}
.row.on {
background: v-bind("tone('success').background");
}
.row.two-line {
height: 60px;
padding: 0 14px;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 4px;
}
.m-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.m-line.sub {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.m-line .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-time,
.m-lang {
flex: none;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.m-lock {
flex: none;
opacity: 0.7;
}
/* 两行排法的 day 行和列表行一样贴两边 */
.mobile .day,
.narrow-list .day {
padding: 0 14px;
}
/* 手机:「共 N 条」和翻页上下排,翻页按钮居中,不再顶出屏幕右边 */
.mobile .list-foot {
height: auto;
padding: 12px 14px 16px;
flex-direction: column;
gap: 8px;
}
.mobile .list-foot .spacer {
display: none;
}
/* 「23:59:59」8 个等宽数字 */
.c-time {
width: 64px;
flex: none;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 按天的分隔行:吸在顶上,往下翻时一直知道在看哪天 */
.day {
position: sticky;
top: 0;
z-index: 1;
height: 32px;
box-sizing: border-box;
padding: 0 var(--oj-pad-x);
display: flex;
align-items: center;
font-size: var(--oj-fs-meta);
font-weight: 600;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.c-user {
width: 116px;
flex: none;
display: flex;
overflow: hidden;
}
.c-problem {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
color: v-bind("theme.textColor2");
}
.c-problem .title {
overflow: hidden;
text-overflow: ellipsis;
}
.pid {
color: v-bind("theme.textColor3");
}
.ps {
flex: none;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 11px;
display: inline-flex;
align-items: center;
color: v-bind("tone('info').color");
background: v-bind("tone('info').background");
}
.c-lang {
width: 50px;
flex: none;
text-align: right;
font-size: var(--oj-fs-meta);
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.c-state {
width: 104px;
flex: none;
display: flex;
justify-content: flex-end;
}
.lock-hint {
display: flex;
}
.c-lock {
width: 14px;
flex: none;
display: flex;
color: v-bind("theme.textColor3");
opacity: 0.6;
}
.empty {
padding: 48px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: v-bind("theme.textColor3");
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: v-bind("theme.textColor2");
}
.list-foot {
height: 52px;
flex: none;
box-sizing: border-box;
padding: 0 16px 0 var(--oj-pad-x);
display: flex;
align-items: center;
gap: 8px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.total {
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
white-space: nowrap;
}
/* Pagination 组件自带上下 20px 外边距和整行宽,放进这条细栏里要收掉 */
.list-foot :deep(.pager) {
margin: 0;
width: auto;
}
.pane-empty {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
color: v-bind("theme.textColor3");
background: v-bind("theme.bodyColor");
}
.mobile-pane {
height: 100%;
display: flex;
}
.mobile-pos {
align-self: center;
color: v-bind("theme.textColor3");
}
</style>