fix(提交列表): 手机上好用多了:筛选里补上「这节课」、班级、只看某个学生,顶上加了统计入口;点开详情名字、题目、按钮分三行排,代码字号改小;流程图的评分不再被压成一条缝;翻页不再顶出屏幕、翻完回到顶上;电脑上「复制回到题目」「复制到自测猫」挪到右栏底栏,老师不用再进「⋯」找,题目名也不再被按钮挤没
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-14 09:21:23 +08:00
1 parent dc89184488
commit c018c147c1
4 files changed
+449 -144

No files matched your search

@@ -169,24 +169,27 @@ const canRetry = computed(
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
</div>
<span class="dot">·</span>
<a
class="problem"
href="#"
:title="`${row.problemDisplayId} ${row.problemTitle}`"
@click.prevent="emit('openProblem', row)"
>
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
class="filter-pill"
title="只看这道题的提交"
aria-label="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
<span v-if="!narrow" class="dot">·</span>
<!-- display: contents 的壳:桌面上照旧一行,手机上题目整行另起 -->
<span class="problem-group">
<a
class="problem"
href="#"
:title="`${row.problemDisplayId} ${row.problemTitle}`"
@click.prevent="emit('openProblem', row)"
>
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
class="filter-pill"
title="只看这道题的提交"
aria-label="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
</span>
<div class="spacer"></div>
<n-button
v-if="shown && !renderError && shown.mermaidCode"
@@ -333,8 +336,36 @@ const canRetry = computed(
row-gap: 8px;
}
.problem-group {
display: contents;
}
/* 手机:第一行名字,第二行题目,第三行「查看大图」「重新评分」 */
.narrow .head .spacer {
display: none;
}
.narrow .problem-group {
flex-basis: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.narrow .problem {
white-space: normal;
}
.narrow .filter-pill {
width: 32px;
height: 28px;
border-radius: 14px;
}
.narrow .meta {
flex-wrap: wrap;
row-gap: 6px;
}
.narrow .body {
@@ -347,6 +378,19 @@ const canRetry = computed(
height: 320px;
}
/* 原来还是 flex: 1 1 0 + overflow: auto:外层不定高,评分那一栏被压成一条缝,只露出半行字 */
.narrow .score-box {
flex: none;
overflow: visible;
}
.narrow .locked {
flex: 1 0 auto;
min-height: 280px;
padding: 16px;
text-align: center;
}
.user-link {
display: flex;
min-width: 0;
@@ -127,29 +127,15 @@ function caseColor(result: number) {
const canRejudge = computed(() => props.teacher && !props.contest)
/**
* 老师的「⋯」:复制两项 + 重新判题。重判不常用、点错了会真的重判,所以不放在外面当最显眼的
* 那个按钮。代码还没拉下来时只有重判一项 —— 代码拉不下来的那条,恰恰可能要重判
* 老师的「⋯」只剩重新判题:不常用、点错了会真的重判,所以不摆在外面。
* 复制两项和学生一样摆出来(原来也收在这里,老师要多点一下)
*/
const moreOptions = computed(() => {
const copy = shown.value
? [
{ label: "复制回到题目", key: "problem" },
...(shown.value.language !== "SQL" ? [{ label: "复制到自测猫", key: "cat" }] : []),
]
: []
if (!canRejudge.value) return copy
return [
...copy,
...(copy.length ? [{ type: "divider", key: "divider" }] : []),
{ label: "重新判题", key: "rejudge" },
]
})
const moreOptions = computed(() =>
canRejudge.value ? [{ label: "重新判题", key: "rejudge" }] : [],
)
function handleMore(key: string) {
if (key === "rejudge") return emit("rejudge", props.row.id)
if (!shown.value) return
if (key === "problem") copyToProblem(shown.value)
else copyToCat(shown.value)
if (key === "rejudge") emit("rejudge", props.row.id)
}
function openStandalone() {
@@ -198,79 +184,87 @@ function openStandalone() {
{{ classLabel(row.className) }}<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
</div>
<span class="dot">·</span>
<a
class="problem"
href="#"
:title="`${row.problemDisplayId} ${row.problemTitle}`"
@click.prevent="emit('openProblem', row)"
>
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
class="filter-pill"
title="只看这道题的提交"
aria-label="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
<a
v-if="row.problemSet"
class="problemset"
:href="'/problemset/' + row.problemSet.id"
target="_blank"
:title="'从题单「' + row.problemSet.title + '」交的'"
>
题单 {{ row.problemSet.title }}
</a>
<span v-if="!narrow" class="dot">·</span>
<!-- 两层 display: contents 的壳:桌面上照旧排成一行,手机上题目整行另起、按钮挪到名字右边 -->
<span class="problem-group">
<a
class="problem"
href="#"
:title="`${row.problemDisplayId} ${row.problemTitle}`"
@click.prevent="emit('openProblem', row)"
>
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
class="filter-pill"
title="只看这道题的提交"
aria-label="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
<a
v-if="row.problemSet"
class="problemset"
:href="'/problemset/' + row.problemSet.id"
target="_blank"
:title="'从题单「' + row.problemSet.title + '」交的'"
>
题单 {{ row.problemSet.title }}
</a>
</span>
<div class="spacer"></div>
<template v-if="teacher">
<n-button
v-if="shown"
size="small"
quaternary
title="新页面打开(看他这道题交的每一次)"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
<n-dropdown
v-if="moreOptions.length"
:options="moreOptions"
trigger="click"
@select="handleMore"
>
<span class="actions">
<!-- 桌面上复制两项在底栏:顶上这一行再塞两个按钮,1280 的屏上老师的题目名一个字都不剩、
学生的也只剩六七个字(第二行测试点一多也排不下,底栏还空 250px 以上) -->
<template v-if="shown && narrow">
<n-button
size="small"
:type="teacher ? 'default' : 'primary'"
@click="copyToProblem(shown)"
>
<template #icon><Icon icon="ph:arrow-u-up-left" /></template>
复制回到题目
</n-button>
<n-button v-if="shown.language !== 'SQL'" size="small" @click="copyToCat(shown)">
复制到自测猫
</n-button>
</template>
<template v-if="teacher">
<n-button
v-if="shown"
size="small"
quaternary
title="新页面打开(看他这道题交的每一次)"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
<n-dropdown
v-if="moreOptions.length"
:options="moreOptions"
trigger="click"
@select="handleMore"
>
<n-button size="small" quaternary aria-label="更多" title="重新判题">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
<template v-else-if="shown">
<n-button
size="small"
quaternary
aria-label="更多"
:title="moreOptions.flatMap((o) => ('label' in o ? [o.label] : [])).join('、')"
title="新页面打开"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:dots-three-bold" /></template>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
</n-dropdown>
</template>
<template v-else-if="shown">
<n-button size="small" type="primary" @click="copyToProblem(shown)">
<template #icon><Icon icon="ph:arrow-u-up-left" /></template>
复制回到题目
</n-button>
<n-button v-if="shown.language !== 'SQL'" size="small" @click="copyToCat(shown)">
复制到自测猫
</n-button>
<n-button
size="small"
quaternary
title="新页面打开"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
</template>
</template>
</span>
</div>
<div class="meta">
@@ -323,6 +317,9 @@ function openStandalone() {
:code="shown.code"
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
:mark-line="explain?.line ?? null"
:font-size="narrow ? 14 : 20"
:line-height="narrow ? 24 : 32"
class="code"
/>
<div v-else class="placeholder">
<n-spin v-if="loading" size="small" />
@@ -335,14 +332,23 @@ function openStandalone() {
<div v-if="!narrow" class="foot">
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
<span>
{{
teacher
? "换上一条 / 下一条,到本页头尾自动翻页"
: "换上一条 / 下一条(只在能看代码的提交之间走)"
}}
<span class="hint">
{{ teacher ? "换上一条 / 下一条,到本页头尾自动翻页" : "换上一条 / 下一条,锁着的会跳过" }}
</span>
<div class="spacer"></div>
<template v-if="shown">
<n-button
size="small"
:type="teacher ? 'default' : 'primary'"
@click="copyToProblem(shown)"
>
<template #icon><Icon icon="ph:arrow-u-up-left" /></template>
复制回到题目
</n-button>
<n-button v-if="shown.language !== 'SQL'" size="small" @click="copyToCat(shown)">
复制到自测猫
</n-button>
</template>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
</div>
</section>
@@ -380,17 +386,74 @@ function openStandalone() {
flex: 0 1 auto;
}
.problem-group,
.actions {
display: contents;
}
/* 手机整屏详情:第一行名字(+ 班级),第二行题目,第三行按钮。
整个面板一起滚,代码有多长画多长,不在框里再套一层上下滚 */
.narrow {
padding: 12px 14px;
padding: 12px 14px 16px;
overflow-y: auto;
}
.narrow .head {
flex-wrap: wrap;
row-gap: 8px;
row-gap: 10px;
white-space: normal;
}
.narrow .head .who {
flex-shrink: 1;
flex-wrap: wrap;
row-gap: 6px;
}
.narrow .head .spacer {
order: 1;
}
.narrow .problem-group {
order: 3;
flex-basis: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.narrow .problem {
white-space: normal;
}
.narrow .actions {
order: 4;
flex-basis: 100%;
display: flex;
align-items: center;
gap: 8px;
}
/* 手指点的:漏斗按钮放大一圈 */
.narrow .filter-pill {
width: 32px;
height: 28px;
border-radius: 14px;
}
.narrow .filter-pill.wide {
width: auto;
padding: 0 10px;
}
.narrow .code {
flex: none;
}
.narrow .locked {
flex: 1 0 auto;
min-height: 280px;
}
.user-link {
@@ -556,14 +619,28 @@ function openStandalone() {
font-weight: 600;
}
/* 高度按按钮定死:代码还在拉、按钮没出来时这一栏不变矮,代码区不跟着上下跳 */
.foot {
min-height: 28px;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: 12px;
color: v-bind("theme.textColor3");
}
/* 1280 的屏上这一栏只剩几像素:字体宽一点的机器上让提示省略,别把按钮挤出去 */
.hint {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.foot > :not(.hint) {
flex-shrink: 0;
}
.keys {
display: inline-flex;
gap: 3px;
+214 -30
View File
@@ -175,6 +175,8 @@ async function listSubmissions() {
// 翻了页、换了条件就回到列表顶上(往回翻到上一页末尾的,settleSelection 会滚到那一条)
const scroller = document.querySelector(".rows")
if (scroller) scroller.scrollTop = 0
// 手机上整页滚,滚的是外层 n-layout 的容器:翻到下一页原来停在页尾
if (isMobile.value) pageScroller()?.scrollTo({ top: 0 })
settleSelection()
} catch {
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
@@ -184,6 +186,19 @@ async function listSubmissions() {
}
}
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)
}
@@ -589,6 +604,20 @@ function openStatistics() {
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<string, string>) {
statsPanel.value = false
@@ -702,7 +731,7 @@ function dayBreak(index: number) {
</script>
<template>
<div class="page" :class="{ contest: inContest, mobile: isMobile }">
<div ref="pageRoot" class="page" :class="{ contest: inContest, mobile: isMobile }">
<!-- 筛选栏 -->
<div v-if="isDesktop" class="filters">
<!-- 流程图列表要登录才能看(GET /flowcharts 是 requireAuth),没登录就不给这个切换 -->
@@ -840,48 +869,117 @@ function dayBreak(index: number) {
</n-button>
</div>
<!-- 手机:只留全部/我的,其余收进「筛选」 -->
<!-- 手机:只留全部/我的、统计,其余收进「筛选」 -->
<div v-else class="filters mobile-filters">
<n-radio-group v-if="userStore.isAuthed" v-model:value="scope" size="small">
<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 size="small" @click="mobileFilters = !mobileFilters">
<!-- 统计那张大表在弹框里排不开,手机上老师直接去统计页(页面形态有手机排版) -->
<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" size="small">
<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"
size="small"
clearable
:disabled="query.myself === '1'"
placeholder="用户"
/>
<n-input v-model:value="query.problem" size="small" clearable placeholder="题号" />
<n-select
v-model:value="query.result"
size="small"
:options="flowMode ? gradeOptions : resultOptions"
/>
<n-select
v-if="!inContest && !flowMode"
v-model:value="query.language"
size="small"
:options="languageOptions"
/>
<n-flex align="center">
<button v-if="!inContest" class="chip" :class="{ on: today }" @click="today = !today">
: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 size="small" quaternary @click="clear">清空</n-button>
</n-flex>
<n-button quaternary @click="clear">清空</n-button>
<div class="spacer"></div>
<n-button @click="mobileFilters = false">收起</n-button>
</div>
</div>
<div class="split">
@@ -922,8 +1020,10 @@ function dayBreak(index: number) {
<FlowchartState :status="row.status" :grade="row.aiGrade" />
</div>
<div class="m-line sub">
<span>{{ submissionClockText(row.createTime) }}</span>
<span class="m-time">{{ submissionClockText(row.createTime) }}</span>
<span class="title">{{ 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>
@@ -979,10 +1079,17 @@ function dayBreak(index: number) {
<StatusPill :result="row.result" />
</div>
<div class="m-line sub">
<span>{{ submissionClockText(row.createTime) }}</span>
<span class="m-time">{{ submissionClockText(row.createTime) }}</span>
<span class="title">{{ row.problemDisplayId }} {{ row.problemTitle }}</span>
<span v-if="row.problemSet" class="ps">题单</span>
<div class="spacer"></div>
<span>{{ languageText(row) }}</span>
<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>
@@ -1180,12 +1287,59 @@ function dayBreak(index: number) {
padding: 0 14px;
}
.mobile-filters {
height: 56px;
gap: 4px;
}
.mobile-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 14px;
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 {
@@ -1416,11 +1570,41 @@ function dayBreak(index: number) {
}
.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 {
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;
@@ -42,7 +42,7 @@ watch(
v-model:page-size="limit"
:page-sizes="sizes"
:page-slot="isDesktop ? 7 : 5"
show-size-picker
:show-size-picker="isDesktop"
/>
</template>
<style scoped>