fix(提交列表/统计): 按方向键会顺手切掉「代码/流程图」、重判后右栏还是旧内容、没人交时统计说「全都做完了」
提交列表: - 焦点在单选框、下拉框上时方向键不再一边切控件一边换条;跨页没回来前不再连跳一整页 - 重判、重新评分之后右栏重新拉,不再命中旧缓存;同一条新旧请求不再互相覆盖 - 从「我的提交」点顶栏「提交」、浏览器后退时,地址里没有的筛选回到默认 - 找回点用户名开个人主页;「只看他 / 只看这题」学生也能用;重判不用等代码拉下来 - 手机上老师也能重判、能按语言筛,流程图行有手机排版;学生界面带着的班级筛选显示成能点掉的标签 - mermaid 画不出来时先说中文、原始报错折起来 统计页: - 没人交时说「这段时间没有人交」,不再说「全都做完了」;没填题号时各处统一叫「做对过题」 - 班级按 class_name 单独传;查询失败清掉旧数据并说清原因;标签跟着查出来的那一份走 - 「语法未过」的格子单独标「语」;正确率不算还在判的;截断提示说清哪些数字受影响 - 看板的请假隐藏只藏还没交过、没在举手的人,「恢复」只恢复这个班的 - 手机上筛选折行、上下排,不再挤出屏幕 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26c8d65e89
commit
0bcc981e6f
15 files changed
+590
-174
No files matched your search
+1
-1
@@ -59,7 +59,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
|
|||||||
|
|
||||||
Shared logic lives in `shared/`:
|
Shared logic lives in `shared/`:
|
||||||
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
|
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
|
||||||
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
|
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (课堂看板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
|
||||||
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ onMounted(loadData)
|
|||||||
<SubmissionDetail
|
<SubmissionDetail
|
||||||
v-if="currentSubmission"
|
v-if="currentSubmission"
|
||||||
:submission="currentSubmission"
|
:submission="currentSubmission"
|
||||||
:problemID="currentSubmission.problemDisplayId"
|
|
||||||
:submissionID="currentSubmission.id"
|
:submissionID="currentSubmission.id"
|
||||||
hideList
|
hideList
|
||||||
@copied="toggleCodePanel(false)"
|
@copied="toggleCodePanel(false)"
|
||||||
|
|||||||
+9
-18
@@ -39,7 +39,6 @@ import {
|
|||||||
type UserBadge,
|
type UserBadge,
|
||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
type SubmissionStatisticsItems,
|
|
||||||
type SubmissionStatisticsGrid,
|
type SubmissionStatisticsGrid,
|
||||||
type TodaySubmissionStatistics,
|
type TodaySubmissionStatistics,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -174,27 +173,15 @@ export function adminRejudge(id: string) {
|
|||||||
return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`)
|
return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
|
||||||
* 和上面那个按班级模糊匹配的不是一回事。
|
|
||||||
*/
|
|
||||||
export function getSubmissionStatisticsItems(
|
|
||||||
duration: { start?: string; end: string },
|
|
||||||
username: string,
|
|
||||||
problemDisplayId?: string,
|
|
||||||
) {
|
|
||||||
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
|
|
||||||
params: { ...duration, problemDisplayId, username },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSubmissionStatistics(
|
export function getSubmissionStatistics(
|
||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
problemDisplayId?: string,
|
problemDisplayId?: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
|
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */
|
||||||
|
className?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<SubmissionStatistics>("submissions/statistics", {
|
return api.get<SubmissionStatistics>("submissions/statistics", {
|
||||||
params: { ...duration, problemDisplayId, username },
|
params: { ...duration, problemDisplayId, username, className },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +190,10 @@ export function getSubmissionStatisticsGrid(
|
|||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
problemDisplayId?: string,
|
problemDisplayId?: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
|
className?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
|
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
|
||||||
params: { ...duration, problemDisplayId, username },
|
params: { ...duration, problemDisplayId, username, className },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +418,8 @@ export function getFlowchartSubmission(id: string) {
|
|||||||
|
|
||||||
export function getFlowchartSubmissions(params: {
|
export function getFlowchartSubmissions(params: {
|
||||||
username?: string
|
username?: string
|
||||||
|
/** "1" = username 整名匹配,同代码提交列表 */
|
||||||
|
exactUsername?: "1"
|
||||||
className?: string
|
className?: string
|
||||||
problemDisplayId?: string
|
problemDisplayId?: string
|
||||||
myself?: string
|
myself?: string
|
||||||
@@ -445,9 +435,10 @@ export function getFlowchartStatistics(
|
|||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
problemDisplayId?: string,
|
problemDisplayId?: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
|
className?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
||||||
params: { ...duration, problemDisplayId, username },
|
params: { ...duration, problemDisplayId, username, className },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,12 +142,19 @@ function nameOf(student: ClassBoardStudent) {
|
|||||||
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
|
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
|
||||||
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
|
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
|
||||||
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
|
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
|
||||||
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着
|
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着。
|
||||||
|
*
|
||||||
|
* **只藏「还没交过」的人**:一个学生只要今天交过、或者正在举手,就不算请假,照常显示、
|
||||||
|
* 照常算进人数。迟到的学生后来交了题、卡住了、举手了,老师得看得见
|
||||||
*/
|
*/
|
||||||
const { hideMode, hideStudent, showAll, isHidden } = useHiddenStudents("oj_hidden_students")
|
const { hideMode, hideStudent, unhide, isHidden } = useHiddenStudents("oj_hidden_students")
|
||||||
const hiddenStudents = computed(() =>
|
function hiddenNow(student: ClassBoardStudent) {
|
||||||
(board.value?.students ?? []).filter((student) => isHidden(student.username)),
|
return !student.lastSubmitAt && !helpOf(student) && isHidden(student.username)
|
||||||
)
|
}
|
||||||
|
const hiddenStudents = computed(() => (board.value?.students ?? []).filter(hiddenNow))
|
||||||
|
function restoreHidden() {
|
||||||
|
unhide(hiddenStudents.value.map((student) => student.username))
|
||||||
|
}
|
||||||
|
|
||||||
const students = computed(() =>
|
const students = computed(() =>
|
||||||
[...(board.value?.students ?? [])]
|
[...(board.value?.students ?? [])]
|
||||||
@@ -328,7 +335,7 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
|
|||||||
<n-alert
|
<n-alert
|
||||||
v-if="idle.length || hiddenStudents.length"
|
v-if="idle.length || hiddenStudents.length"
|
||||||
type="warning"
|
type="warning"
|
||||||
:title="`今天还没交过(${idle.length} 人)`"
|
:title="idle.length ? `今天还没交过(${idle.length} 人)` : '今天没交过的都隐藏了'"
|
||||||
class="section"
|
class="section"
|
||||||
>
|
>
|
||||||
<n-flex align="center" :size="10" class="hide-bar">
|
<n-flex align="center" :size="10" class="hide-bar">
|
||||||
@@ -341,7 +348,7 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
|
|||||||
size="tiny"
|
size="tiny"
|
||||||
text
|
text
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="showAll"
|
@click="restoreHidden"
|
||||||
>
|
>
|
||||||
隐藏了 {{ hiddenStudents.length }} 位:{{ hiddenStudents.map(nameOf).join("、") }} ·
|
隐藏了 {{ hiddenStudents.length }} 位:{{ hiddenStudents.map(nameOf).join("、") }} ·
|
||||||
恢复
|
恢复
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const codeCache = new Map<string, Submission>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
@@ -28,6 +29,7 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
function toneOf(result: number): Tone {
|
function toneOf(result: number): Tone {
|
||||||
if (result === SubmissionStatus.accepted) return "success"
|
if (result === SubmissionStatus.accepted) return "success"
|
||||||
@@ -131,7 +133,8 @@ const cells = computed(() =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
.square:hover {
|
.square:hover {
|
||||||
outline: 2px solid currentColor;
|
/* button 的 color 是浏览器默认的黑,暗色下 currentColor 描边会看不见 */
|
||||||
|
outline: 2px solid v-bind("theme.textColor2");
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { classLabel, submissionDayText } from "oj/submission/utils"
|
|||||||
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
|
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
|
||||||
* 点一个班再看人。「一道没交」要花名册人数,后端在这种情况下给 classSizes。
|
* 点一个班再看人。「一道没交」要花名册人数,后端在这种情况下给 classSizes。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{ grid: SubmissionStatisticsGrid }>()
|
const props = defineProps<{
|
||||||
|
grid: SubmissionStatisticsGrid
|
||||||
|
/** 题号是老师填的。没填时「做完」没法谈(范围里各班各做各的题),改成「做对过题」 */
|
||||||
|
explicit: boolean
|
||||||
|
}>()
|
||||||
const emit = defineEmits<{ pick: [className: string] }>()
|
const emit = defineEmits<{ pick: [className: string] }>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
@@ -36,8 +40,12 @@ const classes = computed(() => {
|
|||||||
const solved = new Set(
|
const solved = new Set(
|
||||||
row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId),
|
row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId),
|
||||||
)
|
)
|
||||||
if (need.every((pid) => solved.has(pid))) done++
|
if (props.explicit ? need.every((pid) => solved.has(pid)) : solved.size > 0) done++
|
||||||
submissions += row.submissions.length
|
// 正确率的分母不算还在判的,和数字行一个口径
|
||||||
|
submissions += row.submissions.filter(
|
||||||
|
(item) =>
|
||||||
|
item.result !== SubmissionStatus.pending && item.result !== SubmissionStatus.judging,
|
||||||
|
).length
|
||||||
accepted += row.submissions.filter((item) => isAc(item.result)).length
|
accepted += row.submissions.filter((item) => isAc(item.result)).length
|
||||||
const lastTime = row.submissions.at(-1)?.createTime ?? ""
|
const lastTime = row.submissions.at(-1)?.createTime ?? ""
|
||||||
if (lastTime > last) last = lastTime
|
if (lastTime > last) last = lastTime
|
||||||
@@ -62,8 +70,8 @@ const classes = computed(() => {
|
|||||||
<div class="by-class">
|
<div class="by-class">
|
||||||
<div class="cols">
|
<div class="cols">
|
||||||
<span class="c-class">班级</span>
|
<span class="c-class">班级</span>
|
||||||
<span class="c-done">做完</span>
|
<span class="c-done">{{ explicit ? "做完" : "做对过题" }}</span>
|
||||||
<span class="c-n">交了没对</span>
|
<span class="c-n">{{ explicit ? "交了没对" : "一道没对" }}</span>
|
||||||
<span class="c-n">没交</span>
|
<span class="c-n">没交</span>
|
||||||
<span class="c-n">正确率</span>
|
<span class="c-n">正确率</span>
|
||||||
<span class="c-last">最近一次</span>
|
<span class="c-last">最近一次</span>
|
||||||
@@ -97,6 +105,9 @@ const classes = computed(() => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
|
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
|
||||||
|
<p v-if="grid.truncated" class="note warn" :style="{ color: tone('warning').color }">
|
||||||
|
范围太大,只取了最近的 5000 条提交,更早的班级可能少算或不在表里。缩短时间段就准了。
|
||||||
|
</p>
|
||||||
<p class="note">没选班级,先按班级列出来;点一个班再看每个人。「做完」是这几道题都做对了。</p>
|
<p class="note">没选班级,先按班级列出来;点一个班再看每个人。「做完」是这几道题都做对了。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,4 +212,22 @@ const classes = computed(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: v-bind("theme.textColor3");
|
color: v-bind("theme.textColor3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机:这张表列多,横着滑 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.by-class {
|
||||||
|
flex: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols,
|
||||||
|
.row {
|
||||||
|
min-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
flex: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -18,10 +18,17 @@ import AttemptSquares from "./AttemptSquares.vue"
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
stats: SubmissionStatistics
|
stats: SubmissionStatistics
|
||||||
grid: SubmissionStatisticsGrid
|
grid: SubmissionStatisticsGrid
|
||||||
|
/**
|
||||||
|
* 题号是老师填的(true),还是没填、从范围里出现过的题凑出来的(false)。没填时一个月
|
||||||
|
* 零零散散几十道题谈不上「全做完」,「做完」改成「做对过题」(做对至少一道),和数字行一致
|
||||||
|
*/
|
||||||
|
explicit: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
|
// 暗色下「做对了」那格的底色是浅绿,白字看不清
|
||||||
|
const isDark = useDark()
|
||||||
|
|
||||||
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
|
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
|
||||||
|
|
||||||
@@ -29,6 +36,10 @@ function isAc(result: number) {
|
|||||||
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
|
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPending(result: number) {
|
||||||
|
return result === SubmissionStatus.pending || result === SubmissionStatus.judging
|
||||||
|
}
|
||||||
|
|
||||||
interface Student {
|
interface Student {
|
||||||
username: string
|
username: string
|
||||||
name: string
|
name: string
|
||||||
@@ -36,6 +47,8 @@ interface Student {
|
|||||||
byProblem: Map<string, Attempt[]>
|
byProblem: Map<string, Attempt[]>
|
||||||
total: number
|
total: number
|
||||||
accepted: number
|
accepted: number
|
||||||
|
/** 判完的条数,正确率的分母(和数字行一个口径:还在判的不算) */
|
||||||
|
judged: number
|
||||||
last: string | null
|
last: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +67,7 @@ const students = computed<Student[]>(() => {
|
|||||||
byProblem,
|
byProblem,
|
||||||
total: row.submissions.length,
|
total: row.submissions.length,
|
||||||
accepted: row.submissions.filter((item) => isAc(item.result)).length,
|
accepted: row.submissions.filter((item) => isAc(item.result)).length,
|
||||||
|
judged: row.submissions.filter((item) => !isPending(item.result)).length,
|
||||||
last: row.submissions.at(-1)?.createTime ?? null,
|
last: row.submissions.at(-1)?.createTime ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -66,6 +80,7 @@ const students = computed<Student[]>(() => {
|
|||||||
byProblem: new Map(),
|
byProblem: new Map(),
|
||||||
total: 0,
|
total: 0,
|
||||||
accepted: 0,
|
accepted: 0,
|
||||||
|
judged: 0,
|
||||||
last: null,
|
last: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -79,6 +94,18 @@ function solvedOn(student: Student, pid: string) {
|
|||||||
return (student.byProblem.get(pid) ?? []).some((item) => isAc(item.result))
|
return (student.byProblem.get(pid) ?? []).some((item) => isAc(item.result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答案对了、但「语法未通过」(AST 规则没按要求写),而且没有一次真正的「答案正确」。
|
||||||
|
* 这类算做对了(全站口径),但教学上没达标,格子上单独标出来
|
||||||
|
*/
|
||||||
|
function astOnlyOn(student: Student, pid: string) {
|
||||||
|
const items = student.byProblem.get(pid) ?? []
|
||||||
|
return (
|
||||||
|
items.some((item) => item.result === SubmissionStatus.ast_check_failed) &&
|
||||||
|
!items.some((item) => item.result === SubmissionStatus.accepted)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function doneCount(student: Student) {
|
function doneCount(student: Student) {
|
||||||
return problems.value.filter((problem) => solvedOn(student, problem.problemDisplayId)).length
|
return problems.value.filter((problem) => solvedOn(student, problem.problemDisplayId)).length
|
||||||
}
|
}
|
||||||
@@ -98,9 +125,14 @@ const problemRows = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const allDone = computed(
|
/** 算「做完」要做对几道:填了题号就要全对,没填就是做对过题 */
|
||||||
() => students.value.filter((student) => doneCount(student) === problems.value.length).length,
|
const needed = computed(() => (props.explicit ? problems.value.length : 1))
|
||||||
)
|
function finished(student: Student) {
|
||||||
|
return problems.value.length > 0 && doneCount(student) >= needed.value
|
||||||
|
}
|
||||||
|
const doneLabel = computed(() => (props.explicit ? "做完" : "做对过题"))
|
||||||
|
|
||||||
|
const allDone = computed(() => students.value.filter(finished).length)
|
||||||
|
|
||||||
// ---------- 选中哪道题、筛哪些人 ----------
|
// ---------- 选中哪道题、筛哪些人 ----------
|
||||||
|
|
||||||
@@ -142,11 +174,16 @@ const problemStudents = computed(() => {
|
|||||||
|
|
||||||
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
|
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
|
||||||
const allStudents = computed(() => {
|
const allStudents = computed(() => {
|
||||||
const n = problems.value.length
|
// 这段时间没人交:没有题可言,别列一排「0 道」
|
||||||
const list = students.value.map((student) => ({ student, done: doneCount(student) }))
|
if (!problems.value.length) return []
|
||||||
const rank = (row: (typeof list)[number]) => (row.done === n ? 2 : row.student.total ? 0 : 1)
|
const list = students.value.map((student) => ({
|
||||||
|
student,
|
||||||
|
done: doneCount(student),
|
||||||
|
finished: finished(student),
|
||||||
|
}))
|
||||||
|
const rank = (row: (typeof list)[number]) => (row.finished ? 2 : row.student.total ? 0 : 1)
|
||||||
return list
|
return list
|
||||||
.filter((row) => !onlyUnfinished.value || row.done < n)
|
.filter((row) => !onlyUnfinished.value || !row.finished)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
rank(a) - rank(b) ||
|
rank(a) - rank(b) ||
|
||||||
@@ -158,7 +195,7 @@ const allStudents = computed(() => {
|
|||||||
const unfinishedCount = computed(() =>
|
const unfinishedCount = computed(() =>
|
||||||
focus.value
|
focus.value
|
||||||
? students.value.filter((student) => !solvedOn(student, focus.value)).length
|
? students.value.filter((student) => !solvedOn(student, focus.value)).length
|
||||||
: students.value.filter((student) => doneCount(student) < problems.value.length).length,
|
: students.value.filter((student) => !finished(student)).length,
|
||||||
)
|
)
|
||||||
|
|
||||||
const expanded = ref("")
|
const expanded = ref("")
|
||||||
@@ -182,7 +219,7 @@ function problemsOf(student: Student) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rate(student: Student) {
|
function rate(student: Student) {
|
||||||
return student.total ? `${Math.round((student.accepted / student.total) * 100)}%` : "—"
|
return student.judged ? `${Math.round((student.accepted / student.judged) * 100)}%` : "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayText(time: string | null) {
|
function dayText(time: string | null) {
|
||||||
@@ -209,7 +246,7 @@ function pct(done: number, total: number) {
|
|||||||
<span class="p-line">
|
<span class="p-line">
|
||||||
<b>全部 {{ problems.length }} 道</b>
|
<b>全部 {{ problems.length }} 道</b>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="muted">做完 {{ allDone }}/{{ personCount }} 人</span>
|
<span class="muted">{{ doneLabel }} {{ allDone }}/{{ personCount }} 人</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="bar"
|
<span class="bar"
|
||||||
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
|
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
|
||||||
@@ -247,18 +284,33 @@ function pct(done: number, total: number) {
|
|||||||
{{ Math.max(0, personCount - focusRow.tried) }}
|
{{ Math.max(0, personCount - focusRow.tried) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="!problems.length">
|
||||||
|
<b class="s-title">这段时间没有人交</b>
|
||||||
|
<span class="muted">换个时间段试试,比如「今天」或「最近 7 天」</span>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<b class="s-title">全部 {{ problems.length }} 道</b>
|
<b class="s-title">全部 {{ problems.length }} 道</b>
|
||||||
<span class="muted">
|
<span class="muted">
|
||||||
做完 {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} · 一道没交
|
{{ doneLabel }} {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} ·
|
||||||
|
一道没交
|
||||||
{{ stats.dataUnaccepted.length }}
|
{{ stats.dataUnaccepted.length }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="chip" :class="{ on: onlyUnfinished }" @click="onlyUnfinished = true">
|
<button
|
||||||
|
v-if="problems.length"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: onlyUnfinished }"
|
||||||
|
@click="onlyUnfinished = true"
|
||||||
|
>
|
||||||
没做完 <span class="n">{{ unfinishedCount }}</span>
|
没做完 <span class="n">{{ unfinishedCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="chip" :class="{ on: !onlyUnfinished }" @click="onlyUnfinished = false">
|
<button
|
||||||
|
v-if="problems.length"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: !onlyUnfinished }"
|
||||||
|
@click="onlyUnfinished = false"
|
||||||
|
>
|
||||||
全部 <span class="n">{{ students.length }}</span>
|
全部 <span class="n">{{ students.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +341,7 @@ function pct(done: number, total: number) {
|
|||||||
|
|
||||||
<!-- 全部题:每人一行,一格一道题;点开按题看方块串 -->
|
<!-- 全部题:每人一行,一格一道题;点开按题看方块串 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="cols">
|
<div v-if="problems.length" class="cols">
|
||||||
<span class="c-caret"></span>
|
<span class="c-caret"></span>
|
||||||
<span class="c-name">学生</span>
|
<span class="c-name">学生</span>
|
||||||
<span class="c-done">做完</span>
|
<span class="c-done">做完</span>
|
||||||
@@ -302,7 +354,12 @@ function pct(done: number, total: number) {
|
|||||||
<div
|
<div
|
||||||
class="row clickable"
|
class="row clickable"
|
||||||
:class="{ on: expanded === row.student.username }"
|
:class="{ on: expanded === row.student.username }"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="expanded === row.student.username"
|
||||||
@click="toggle(row.student.username)"
|
@click="toggle(row.student.username)"
|
||||||
|
@keydown.enter.prevent="toggle(row.student.username)"
|
||||||
|
@keydown.space.prevent="toggle(row.student.username)"
|
||||||
>
|
>
|
||||||
<span class="c-caret">
|
<span class="c-caret">
|
||||||
<Icon
|
<Icon
|
||||||
@@ -316,15 +373,14 @@ function pct(done: number, total: number) {
|
|||||||
<span
|
<span
|
||||||
class="c-done done"
|
class="c-done done"
|
||||||
:style="{
|
:style="{
|
||||||
color:
|
color: row.finished
|
||||||
row.done === problems.length
|
|
||||||
? tone('success').color
|
? tone('success').color
|
||||||
: row.student.total
|
: row.student.total
|
||||||
? theme.textColor1
|
? theme.textColor1
|
||||||
: tone('error').color,
|
: tone('error').color,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ row.done }}/{{ problems.length }}
|
{{ explicit ? `${row.done}/${problems.length}` : `${row.done} 道` }}
|
||||||
</span>
|
</span>
|
||||||
<span class="c-n muted">{{ row.student.total }}</span>
|
<span class="c-n muted">{{ row.student.total }}</span>
|
||||||
<span class="c-rate muted">{{ rate(row.student) }}</span>
|
<span class="c-rate muted">{{ rate(row.student) }}</span>
|
||||||
@@ -333,17 +389,28 @@ function pct(done: number, total: number) {
|
|||||||
v-for="problem in problems"
|
v-for="problem in problems"
|
||||||
:key="problem.problemDisplayId"
|
:key="problem.problemDisplayId"
|
||||||
class="cell"
|
class="cell"
|
||||||
:title="problem.problemDisplayId + ' ' + problem.title"
|
:title="
|
||||||
|
problem.problemDisplayId +
|
||||||
|
' ' +
|
||||||
|
problem.title +
|
||||||
|
(astOnlyOn(row.student, problem.problemDisplayId)
|
||||||
|
? ':答案对了,但没按要求的写法写'
|
||||||
|
: '')
|
||||||
|
"
|
||||||
:style="
|
:style="
|
||||||
solvedOn(row.student, problem.problemDisplayId)
|
astOnlyOn(row.student, problem.problemDisplayId)
|
||||||
? { background: tone('success').solid, color: '#fff' }
|
? { background: tone('warning').background, color: tone('warning').color }
|
||||||
|
: solvedOn(row.student, problem.problemDisplayId)
|
||||||
|
? { background: tone('success').solid, color: isDark ? '#18181c' : '#fff' }
|
||||||
: row.student.byProblem.has(problem.problemDisplayId)
|
: row.student.byProblem.has(problem.problemDisplayId)
|
||||||
? { background: tone('error').background, color: tone('error').color }
|
? { background: tone('error').background, color: tone('error').color }
|
||||||
: { background: theme.actionColor, color: theme.textColor3 }
|
: { background: theme.actionColor, color: theme.textColor3 }
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
solvedOn(row.student, problem.problemDisplayId)
|
astOnlyOn(row.student, problem.problemDisplayId)
|
||||||
|
? "语"
|
||||||
|
: solvedOn(row.student, problem.problemDisplayId)
|
||||||
? "✓"
|
? "✓"
|
||||||
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
|
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
|
||||||
}}
|
}}
|
||||||
@@ -383,11 +450,12 @@ function pct(done: number, total: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!allStudents.length" class="empty">全都做完了</div>
|
<div v-if="problems.length && !allStudents.length" class="empty">全都做完了</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="grid.truncated" class="truncated">
|
<div v-if="grid.truncated" class="truncated">
|
||||||
范围太大,方块串只画了最近 5000 条;上面的人数、正确率仍是全部算的。
|
范围太大,只取了最近的 5000 条提交:上面一排数字是全部算的,左边每道题的「做完」、
|
||||||
|
右边的名单和方块串只按这 5000 条算,更早交的人可能不在里面。缩短时间段就准了。
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -695,4 +763,55 @@ function pct(done: number, total: number) {
|
|||||||
color: v-bind("tone('warning').color");
|
color: v-bind("tone('warning').color");
|
||||||
border-top: 1px solid v-bind("theme.dividerColor");
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机:题目在上、学生在下,整页滚动 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.code-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problems {
|
||||||
|
width: 100%;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem-list,
|
||||||
|
.rows {
|
||||||
|
flex: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-head {
|
||||||
|
height: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 14px;
|
||||||
|
row-gap: 6px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 14px;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-seq,
|
||||||
|
.c-grid {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-when {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -56,9 +56,11 @@ const gradeColor = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
|
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
|
||||||
const goodCount = computed(
|
const goodCount = computed(() => {
|
||||||
() => props.data.people.filter((row) => row.bestGrade === "S" || row.bestGrade === "A").length,
|
// 查几道题就要每道都拿到 A / S;没填题号时拿到一道就算
|
||||||
)
|
const need = Math.max(1, props.data.problemCount)
|
||||||
|
return props.data.people.filter((row) => row.goodProblems >= need).length
|
||||||
|
})
|
||||||
const personCount = computed(() => props.data.personCount || props.data.people.length)
|
const personCount = computed(() => props.data.personCount || props.data.people.length)
|
||||||
|
|
||||||
const gradeData = computed(() => ({
|
const gradeData = computed(() => ({
|
||||||
@@ -285,6 +287,7 @@ function gradeStyle(grade: string | null) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card people">
|
<div class="card people">
|
||||||
<div class="card-title">每人最好的一次(没交的在前)</div>
|
<div class="card-title">每人最好的一次(没交的在前)</div>
|
||||||
|
<div v-if="data.peopleTruncated" class="muted">人太多,只列了成绩靠后的 500 位</div>
|
||||||
<div class="people-list">
|
<div class="people-list">
|
||||||
<div v-for="row in people" :key="row.username" class="person">
|
<div v-for="row in people" :key="row.username" class="person">
|
||||||
<b class="p-name" :title="row.username">{{ row.name }}</b>
|
<b class="p-name" :title="row.username">{{ row.name }}</b>
|
||||||
@@ -447,4 +450,47 @@ function gradeStyle(grade: string | null) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: v-bind("theme.textColor3");
|
color: v-bind("theme.textColor3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机:卡片一张一行 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.flow-stats {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
height: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 24px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-cards,
|
||||||
|
.row-cards.bottom {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.grade,
|
||||||
|
.card.radar,
|
||||||
|
.card.people {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .chart,
|
||||||
|
.card .cloud {
|
||||||
|
flex: none;
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.people .people-list {
|
||||||
|
flex: none;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -36,7 +36,6 @@ const router = useRouter()
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
|
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
|
||||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||||
@@ -52,9 +51,12 @@ const periodValue = computed<Period>(() =>
|
|||||||
: DEFAULT_PERIOD,
|
: DEFAULT_PERIOD,
|
||||||
)
|
)
|
||||||
|
|
||||||
const custom = computed<[number, number] | null>(() =>
|
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
|
||||||
from.value && to.value ? [Number(from.value), Number(to.value)] : null,
|
const custom = computed<[number, number] | null>(() => {
|
||||||
)
|
const a = Number(from.value)
|
||||||
|
const b = Number(to.value)
|
||||||
|
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
||||||
|
})
|
||||||
|
|
||||||
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
||||||
const pickerValue = computed<[number, number] | null>(() =>
|
const pickerValue = computed<[number, number] | null>(() =>
|
||||||
@@ -70,22 +72,8 @@ function pickDates(value: [number, number] | null) {
|
|||||||
to.value = String(fromPickerValue(value[1]))
|
to.value = String(fromPickerValue(value[1]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
|
||||||
* 统计接口的「用户名」是 ilike:填 `ks253` 圈一个班。选了班级又填了学生时按学生查,
|
const studentParam = computed(() => username.value.trim())
|
||||||
* 学生名字不带班级前缀的话补上,免得别的班重名的混进来
|
|
||||||
*/
|
|
||||||
const usernameParam = computed(() => {
|
|
||||||
const name = username.value.trim()
|
|
||||||
if (name) {
|
|
||||||
return className.value && !name.toLowerCase().startsWith("ks")
|
|
||||||
? `ks${className.value}${name}`
|
|
||||||
: name
|
|
||||||
}
|
|
||||||
return className.value ? `ks${className.value}` : ""
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 没选班级、没填学生:先按班级汇总(多半是从题目页进来的) */
|
|
||||||
const byClass = computed(() => !usernameParam.value)
|
|
||||||
|
|
||||||
const classOptions = computed(() => {
|
const classOptions = computed(() => {
|
||||||
const list = configStore.config?.classList ?? []
|
const list = configStore.config?.classList ?? []
|
||||||
@@ -96,42 +84,64 @@ const classOptions = computed(() => {
|
|||||||
// ---------- 取数 ----------
|
// ---------- 取数 ----------
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const failed = ref(false)
|
const failed = ref("")
|
||||||
const stats = ref<SubmissionStatistics | null>(null)
|
const stats = ref<SubmissionStatistics | null>(null)
|
||||||
const grid = ref<SubmissionStatisticsGrid | null>(null)
|
const grid = ref<SubmissionStatisticsGrid | null>(null)
|
||||||
const flow = ref<FlowchartStatistics | null>(null)
|
const flow = ref<FlowchartStatistics | null>(null)
|
||||||
const rangeText = ref("")
|
|
||||||
const withinToday = ref(false)
|
/**
|
||||||
|
* 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是
|
||||||
|
* 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是
|
||||||
|
* 上一次的,标签先变了就对不上
|
||||||
|
*/
|
||||||
|
const shown = ref({ byClass: true, explicit: false, rangeText: "", withinToday: false })
|
||||||
let seq = 0
|
let seq = 0
|
||||||
|
|
||||||
|
const ERROR_TEXT: Record<string, string> = {
|
||||||
|
"problem-not-found": "有题号找不到,检查一下是不是打错了",
|
||||||
|
"invalid-request": "一次最多查 20 道题",
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const current = ++seq
|
const current = ++seq
|
||||||
const range = periodRange(periodValue.value, custom.value)
|
const range = periodRange(periodValue.value, custom.value)
|
||||||
rangeText.value = range.text
|
|
||||||
withinToday.value = range.withinToday
|
|
||||||
const duration = { start: range.start, end: range.end }
|
const duration = { start: range.start, end: range.end }
|
||||||
|
const snapshot = {
|
||||||
|
byClass: !studentParam.value && !className.value,
|
||||||
|
explicit: !!problem.value.trim(),
|
||||||
|
rangeText: range.text,
|
||||||
|
withinToday: range.withinToday,
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
failed.value = false
|
failed.value = ""
|
||||||
try {
|
try {
|
||||||
if (tab.value === "flow") {
|
if (tab.value === "flow") {
|
||||||
const res = await getFlowchartStatistics(duration, problem.value, usernameParam.value)
|
const res = await getFlowchartStatistics(
|
||||||
|
duration,
|
||||||
|
problem.value,
|
||||||
|
studentParam.value,
|
||||||
|
className.value,
|
||||||
|
)
|
||||||
if (current !== seq) return
|
if (current !== seq) return
|
||||||
flow.value = res
|
flow.value = res
|
||||||
} else {
|
} else {
|
||||||
const [s, g] = await Promise.all([
|
const [s, g] = await Promise.all([
|
||||||
getSubmissionStatistics(duration, problem.value, usernameParam.value),
|
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
||||||
getSubmissionStatisticsGrid(duration, problem.value, usernameParam.value),
|
getSubmissionStatisticsGrid(duration, problem.value, studentParam.value, className.value),
|
||||||
])
|
])
|
||||||
if (current !== seq) return
|
if (current !== seq) return
|
||||||
stats.value = s
|
stats.value = s
|
||||||
grid.value = g
|
grid.value = g
|
||||||
}
|
}
|
||||||
|
shown.value = snapshot
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (current !== seq) return
|
if (current !== seq) return
|
||||||
failed.value = true
|
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
|
||||||
// 题号打错是最常见的:后端 404 problem-not-found
|
stats.value = null
|
||||||
const code = (error as { error?: string })?.error
|
grid.value = null
|
||||||
if (code === "problem-not-found") message.warning("有题号找不到,检查一下是不是打错了")
|
flow.value = null
|
||||||
|
const code = (error as { error?: string })?.error ?? ""
|
||||||
|
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
|
||||||
} finally {
|
} finally {
|
||||||
if (current === seq) loading.value = false
|
if (current === seq) loading.value = false
|
||||||
}
|
}
|
||||||
@@ -146,9 +156,9 @@ watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
|
|||||||
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
|
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
|
||||||
/**
|
/**
|
||||||
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
|
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
|
||||||
* 「全做完」。这时这一格叫「做对过题」,别和左边「全部 N 道 · 做完 0 人」打架
|
* 「全做完」。这时各处都叫「做对过题」(CodeStats、ByClass 同一个口径)
|
||||||
*/
|
*/
|
||||||
const hasProblems = computed(() => !!problem.value.trim())
|
const hasProblems = computed(() => shown.value.explicit)
|
||||||
const personCount = computed(() => stats.value?.personCount ?? 0)
|
const personCount = computed(() => stats.value?.personCount ?? 0)
|
||||||
const judged = computed(() =>
|
const judged = computed(() =>
|
||||||
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
||||||
@@ -164,10 +174,10 @@ function openList() {
|
|||||||
name: "submissions",
|
name: "submissions",
|
||||||
query: {
|
query: {
|
||||||
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
|
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
|
||||||
...(className.value && !username.value ? { className: className.value } : {}),
|
...(className.value ? { className: className.value } : {}),
|
||||||
...(username.value ? { username: usernameParam.value } : {}),
|
...(studentParam.value ? { username: studentParam.value } : {}),
|
||||||
...(problem.value ? { problem: problem.value } : {}),
|
...(problem.value ? { problem: problem.value } : {}),
|
||||||
...(withinToday.value ? { today: "1" } : {}),
|
...(shown.value.withinToday ? { today: "1" } : {}),
|
||||||
},
|
},
|
||||||
}).href
|
}).href
|
||||||
window.open(href, "_blank")
|
window.open(href, "_blank")
|
||||||
@@ -227,9 +237,12 @@ function pickClass(value: string) {
|
|||||||
clearable
|
clearable
|
||||||
@update:value="pickDates"
|
@update:value="pickDates"
|
||||||
/>
|
/>
|
||||||
<span v-else class="range">{{ rangeText }}</span>
|
<span v-else class="range">{{ shown.rangeText }}</span>
|
||||||
<n-spin v-if="loading" :size="14" />
|
<n-spin v-if="loading" :size="14" />
|
||||||
<n-text v-if="failed" type="error" class="range">没拉下来,改一下条件再试</n-text>
|
<n-text v-if="failed" type="error" class="range">{{ failed }}</n-text>
|
||||||
|
<span v-if="periodValue === 'custom' && !custom" class="range"
|
||||||
|
>选一下开始和结束的日子,没选先按今天算</span
|
||||||
|
>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -256,7 +269,7 @@ function pickClass(value: string) {
|
|||||||
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
||||||
<StatItem
|
<StatItem
|
||||||
label="正确率"
|
label="正确率"
|
||||||
:value="`${Math.round(stats.correctRate)}%`"
|
:value="stats.submissionCount ? `${Math.round(stats.correctRate)}%` : '—'"
|
||||||
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
||||||
/>
|
/>
|
||||||
<template v-if="personCount">
|
<template v-if="personCount">
|
||||||
@@ -299,8 +312,8 @@ function pickClass(value: string) {
|
|||||||
<span class="ring-label">班级<br />完成度</span>
|
<span class="ring-label">班级<br />完成度</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ByClass v-if="byClass" :grid="grid" @pick="pickClass" />
|
<ByClass v-if="shown.byClass" :grid="grid" :explicit="shown.explicit" @pick="pickClass" />
|
||||||
<CodeStats v-else :stats="stats" :grid="grid" />
|
<CodeStats v-else :stats="stats" :grid="grid" :explicit="shown.explicit" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -394,4 +407,41 @@ function pickClass(value: string) {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: v-bind("theme.textColor3");
|
color: v-bind("theme.textColor3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机:老师很少在手机上看统计,只保证不坏 —— 筛选和数字行折行,整页自然滚动 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters,
|
||||||
|
.summary {
|
||||||
|
height: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 14px;
|
||||||
|
row-gap: 10px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
gap: 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsep,
|
||||||
|
.spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-class,
|
||||||
|
.w-user,
|
||||||
|
.w-period {
|
||||||
|
width: calc(50% - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-problem,
|
||||||
|
.w-dates {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -35,11 +35,15 @@ function todayStart(now: number) {
|
|||||||
return dayStartOf(p.year, p.month, p.day)
|
return dayStartOf(p.year, p.month, p.day)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 这学期从哪天起:9 月到次年 2 月算秋季学期(9 月 1 日起),3 到 8 月算春季(3 月 1 日起) */
|
/**
|
||||||
|
* 这学期从哪天起:9 月到次年 1 月算秋季学期(9 月 1 日起),2 到 8 月算春季(2 月 1 日起)。
|
||||||
|
* 春季不从 3 月算:2 月下旬就开学了,按 3 月 1 日算的话开学头一两周「这学期」会把整个
|
||||||
|
* 上学期和寒假都带进来
|
||||||
|
*/
|
||||||
function termStart(now: number) {
|
function termStart(now: number) {
|
||||||
const p = zonedParts(new Date(now))!
|
const p = zonedParts(new Date(now))!
|
||||||
if (p.month >= 9) return dayStartOf(p.year, 9, 1)
|
if (p.month >= 9) return dayStartOf(p.year, 9, 1)
|
||||||
if (p.month >= 3) return dayStartOf(p.year, 3, 1)
|
if (p.month >= 2) return dayStartOf(p.year, 2, 1)
|
||||||
return dayStartOf(p.year - 1, 9, 1)
|
return dayStartOf(p.year - 1, 9, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,29 +49,41 @@ const evaluating = computed(
|
|||||||
props.row.status === FlowchartSubmissionStatus.PROCESSING,
|
props.row.status === FlowchartSubmissionStatus.PROCESSING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 缓存,重新评分一开始(状态回到排队 / 评分中)就把这一条扔掉。原来按「id + 状态」
|
||||||
|
* 缓存,评完 → 排队 → 评分中 → 评完 转一圈回来正好命中重评之前的旧评语
|
||||||
|
*/
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const row = props.row
|
const row = props.row
|
||||||
|
const seq = ++loadSeq
|
||||||
failed.value = false
|
failed.value = false
|
||||||
bigOpen.value = false
|
bigOpen.value = false
|
||||||
|
if (evaluating.value) cache.delete(row.id)
|
||||||
if (!row.showLink) {
|
if (!row.showLink) {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const key = `${row.id}:${row.status}`
|
let res = cache.get(row.id)
|
||||||
let res = cache.get(key)
|
if (!res || res.status !== row.status) {
|
||||||
if (!res) {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
res = await getFlowchartSubmission(row.id)
|
res = await getFlowchartSubmission(row.id)
|
||||||
if (!evaluating.value) cache.set(key, res)
|
if (seq !== loadSeq) return
|
||||||
|
if (!evaluating.value && res.status === props.row.status) cache.set(row.id, res)
|
||||||
} catch {
|
} catch {
|
||||||
if (props.row.id === row.id) failed.value = true
|
if (seq !== loadSeq) return
|
||||||
|
failed.value = true
|
||||||
|
// 别让上一条的图留在那儿,旁边却写着「没拉下来」
|
||||||
|
detail.value = null
|
||||||
|
if (graph.value) graph.value.innerHTML = ""
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
if (props.row.id === row.id) loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (props.row.id !== row.id) return
|
if (seq !== loadSeq) return
|
||||||
detail.value = res
|
detail.value = res
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await renderFlowchart(graph.value, res.mermaidCode)
|
await renderFlowchart(graph.value, res.mermaidCode)
|
||||||
@@ -131,8 +143,14 @@ const canRetry = computed(
|
|||||||
<section class="pane" :class="{ narrow }">
|
<section class="pane" :class="{ narrow }">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="who">
|
<div class="who">
|
||||||
|
<a
|
||||||
|
class="user-link"
|
||||||
|
:href="`/user?name=${encodeURIComponent(row.username)}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<UserName :username="row.username" />
|
<UserName :username="row.username" />
|
||||||
<button v-if="teacher" class="filter-pill" @click="emit('filterUser', row.username)">
|
</a>
|
||||||
|
<button class="filter-pill" @click="emit('filterUser', row.username)">
|
||||||
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他
|
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,11 +159,7 @@ const canRetry = computed(
|
|||||||
<span class="problem-id">{{ row.problemDisplayId }}</span>
|
<span class="problem-id">{{ row.problemDisplayId }}</span>
|
||||||
{{ row.problemTitle }}
|
{{ row.problemTitle }}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button class="filter-pill" @click="emit('filterProblem', row.problemDisplayId)">
|
||||||
v-if="teacher"
|
|
||||||
class="filter-pill"
|
|
||||||
@click="emit('filterProblem', row.problemDisplayId)"
|
|
||||||
>
|
|
||||||
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
|
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
|
||||||
</button>
|
</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
@@ -171,13 +185,22 @@ const canRetry = computed(
|
|||||||
|
|
||||||
<div v-if="!row.showLink" class="locked">
|
<div v-if="!row.showLink" class="locked">
|
||||||
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
|
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
|
||||||
<span class="locked-title">别人画的流程图看不到</span>
|
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
|
||||||
|
<span class="locked-title">这张流程图看不到</span>
|
||||||
|
<span class="muted">别人画的看不到;自己的在题单里做完这道题就能看到。</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="body">
|
<div v-else class="body">
|
||||||
<div class="graph-box">
|
<div class="graph-box">
|
||||||
<n-alert v-if="renderError" type="error" title="流程图画不出来">
|
<!-- mermaid 的报错是英文,学生看自己的图也会碰到:先说中文,原文折起来 -->
|
||||||
{{ renderError }}
|
<div v-if="renderError" class="render-error">
|
||||||
</n-alert>
|
<b>这张流程图画不出来</b>
|
||||||
|
<span class="muted">可能是画的时候有框没连上,或者文字里有特殊符号。</span>
|
||||||
|
<n-collapse>
|
||||||
|
<n-collapse-item title="原始报错" name="raw">
|
||||||
|
<pre class="raw">{{ renderError }}</pre>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
</div>
|
||||||
<div v-show="!renderError" ref="graph" class="graph"></div>
|
<div v-show="!renderError" ref="graph" class="graph"></div>
|
||||||
<n-spin v-if="loading && !shown" size="small" />
|
<n-spin v-if="loading && !shown" size="small" />
|
||||||
<span v-if="failed" class="muted"
|
<span v-if="failed" class="muted"
|
||||||
@@ -277,6 +300,29 @@ const canRetry = computed(
|
|||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-link {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.who {
|
.who {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import UserName from "./UserName.vue"
|
|||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
row: SubmissionListItem
|
row: SubmissionListItem
|
||||||
/** 老师那套:「只看他」「只看这题」、重新判题、逐个测试点 */
|
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
|
||||||
teacher: boolean
|
teacher: boolean
|
||||||
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
|
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
|
||||||
contest: boolean
|
contest: boolean
|
||||||
@@ -55,8 +55,9 @@ const tone = useTone()
|
|||||||
const { copyToCat, copyToProblem } = useCopySubmission()
|
const { copyToCat, copyToProblem } = useCopySubmission()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情按「id + 结果」缓存:同一条判完之前结果会变,变了要重拉;判完的代码不会再变,
|
* 详情按提交 id 缓存:判完的代码和结果不会再变,老师在几行之间来回按方向键时不用每次都等网络。
|
||||||
* 老师在几行之间来回按方向键时不用每次都等网络。
|
* 还在判的不进缓存;一条被重判(结果回到「等待评分」)就把它的缓存扔掉 —— 重判后结果
|
||||||
|
* 可能没变(答案错误还是答案错误),但测试点和报错是新的
|
||||||
*/
|
*/
|
||||||
const cache = new Map<string, Submission>()
|
const cache = new Map<string, Submission>()
|
||||||
const detail = ref<Submission | null>(null)
|
const detail = ref<Submission | null>(null)
|
||||||
@@ -68,30 +69,34 @@ const judging = computed(
|
|||||||
props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging,
|
props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const row = props.row
|
const row = props.row
|
||||||
|
const seq = ++loadSeq
|
||||||
failed.value = false
|
failed.value = false
|
||||||
|
if (judging.value) cache.delete(row.id)
|
||||||
if (!row.showLink) {
|
if (!row.showLink) {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const key = `${row.id}:${row.result}`
|
const hit = cache.get(row.id)
|
||||||
const hit = cache.get(key)
|
if (hit && hit.result === row.result) {
|
||||||
if (hit) {
|
|
||||||
detail.value = hit
|
detail.value = hit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 换行时先别清空:接口一般几十毫秒就回来,清了会闪一下白
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getSubmission(row.id)
|
const res = await getSubmission(row.id)
|
||||||
if (props.row.id !== row.id) return
|
if (seq !== loadSeq) return
|
||||||
if (!judging.value) cache.set(key, res)
|
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
|
||||||
|
if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
|
||||||
detail.value = res
|
detail.value = res
|
||||||
} catch {
|
} catch {
|
||||||
if (props.row.id === row.id) failed.value = true
|
if (seq === loadSeq) failed.value = true
|
||||||
} finally {
|
} finally {
|
||||||
if (props.row.id === row.id) loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +227,15 @@ function openStandalone() {
|
|||||||
<section class="pane" :class="{ narrow }">
|
<section class="pane" :class="{ narrow }">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="who">
|
<div class="who">
|
||||||
|
<!-- 点名字开个人主页(原来列表里的用户名就是这个链接) -->
|
||||||
|
<a
|
||||||
|
class="user-link"
|
||||||
|
:href="`/user?name=${encodeURIComponent(row.username)}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<UserName :username="row.username" :muted="!!locked && locked !== 'problemset'" />
|
<UserName :username="row.username" :muted="!!locked && locked !== 'problemset'" />
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
v-if="teacher"
|
|
||||||
class="filter-pill"
|
class="filter-pill"
|
||||||
title="只看这个人的提交"
|
title="只看这个人的提交"
|
||||||
@click="emit('filterUser', row.username)"
|
@click="emit('filterUser', row.username)"
|
||||||
@@ -238,7 +249,6 @@ function openStandalone() {
|
|||||||
{{ row.problemTitle }}
|
{{ row.problemTitle }}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
v-if="teacher"
|
|
||||||
class="filter-pill"
|
class="filter-pill"
|
||||||
title="只看这道题的提交"
|
title="只看这道题的提交"
|
||||||
@click="emit('filterProblem', row.problemDisplayId)"
|
@click="emit('filterProblem', row.problemDisplayId)"
|
||||||
@@ -255,12 +265,13 @@ function openStandalone() {
|
|||||||
题单 {{ row.problemSet.title }}
|
题单 {{ row.problemSet.title }}
|
||||||
</a>
|
</a>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<template v-if="shown">
|
<!-- 重判不用等代码拉下来:代码拉不下来的那条,恰恰可能要重判 -->
|
||||||
<template v-if="teacher">
|
|
||||||
<n-button v-if="canRejudge" size="small" @click="emit('rejudge', row.id)">
|
<n-button v-if="canRejudge" size="small" @click="emit('rejudge', row.id)">
|
||||||
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
|
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
|
||||||
重新判题
|
重新判题
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<template v-if="shown">
|
||||||
|
<template v-if="teacher">
|
||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
quaternary
|
quaternary
|
||||||
@@ -429,6 +440,16 @@ function openStandalone() {
|
|||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-link {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-link:hover :deep(.name) {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
.who {
|
.who {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useCopySubmission } from "./composables/copySubmission"
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
submissionID: string
|
submissionID: string
|
||||||
problemID?: string
|
|
||||||
submission?: Submission
|
submission?: Submission
|
||||||
hideList?: boolean
|
hideList?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils"
|
|||||||
* 完成情况在课堂看板和「数据统计」里,这页不重复造一张表。
|
* 完成情况在课堂看板和「数据统计」里,这页不重复造一张表。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 右栏和两个统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
|
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
|
||||||
// (光 chart.js 就 197KB)
|
|
||||||
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
|
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
|
||||||
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
|
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
|
||||||
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
||||||
@@ -128,6 +127,9 @@ function fetchPage() {
|
|||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
today: query.today,
|
today: query.today,
|
||||||
grade: query.result,
|
grade: query.result,
|
||||||
|
...(exactFor.value && query.username === exactFor.value
|
||||||
|
? { exactUsername: "1" as const }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return getSubmissions({
|
return getSubmissions({
|
||||||
@@ -162,7 +164,13 @@ async function listSubmissions() {
|
|||||||
total.value = res.total
|
total.value = res.total
|
||||||
newCount.value = 0
|
newCount.value = 0
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
|
// 翻了页、换了条件就回到列表顶上(往回翻到上一页末尾的,settleSelection 会滚到那一条)
|
||||||
|
const scroller = document.querySelector(".rows")
|
||||||
|
if (scroller) scroller.scrollTop = 0
|
||||||
settleSelection()
|
settleSelection()
|
||||||
|
} catch {
|
||||||
|
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
|
||||||
|
if (seq === requestSeq) pendingJump = null
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) loading.value = false
|
if (seq === requestSeq) loading.value = false
|
||||||
}
|
}
|
||||||
@@ -201,8 +209,39 @@ watch(
|
|||||||
if (route.name === "submissions") getTodayCount()
|
if (route.name === "submissions") getTodayCount()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// 登录态一变,能看哪些代码就变了(showLink 是后端逐行算的)
|
// 登录态一变,能看哪些代码就变了(showLink 是后端逐行算的)。掉了登录还停在流程图上的话
|
||||||
watch(() => userStore.isAuthed, listSubmissions)
|
// 退回代码:流程图列表要登录才能看,而没登录时那个切换按钮不显示,会卡在一片报错里出不去
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ==================== 选中与翻阅 ====================
|
// ==================== 选中与翻阅 ====================
|
||||||
|
|
||||||
@@ -254,6 +293,8 @@ const maxPage = computed(() => Math.max(1, Math.ceil(total.value / query.limit))
|
|||||||
function move(step: 1 | -1) {
|
function move(step: 1 | -1) {
|
||||||
// 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来
|
// 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来
|
||||||
if (!navigable.value.length) return
|
if (!navigable.value.length) return
|
||||||
|
// 上一次跨页还没回来:按住 ↓ 或者在页尾连按两下,不然会整页跳过
|
||||||
|
if (loading.value || pendingJump) return
|
||||||
const all = rows.value
|
const all = rows.value
|
||||||
const start = all.findIndex((row) => row.id === selectedId.value)
|
const start = all.findIndex((row) => row.id === selectedId.value)
|
||||||
for (let i = start + step; i >= 0 && i < all.length; i += step) {
|
for (let i = start + step; i >= 0 && i < all.length; i += step) {
|
||||||
@@ -272,17 +313,22 @@ const [todayPanel, toggleTodayPanel] = useToggle(false)
|
|||||||
|
|
||||||
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
|
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
|
||||||
if (!isDesktop.value || todayPanel.value) return
|
if (!isDesktop.value || todayPanel.value) return
|
||||||
// 焦点在输入框、下拉框里时不抢方向键
|
// 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项)
|
||||||
|
if (e.defaultPrevented) return
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
|
if (target) {
|
||||||
|
// 「代码 / 流程图」「全部 / 我的」是单选框:点过之后焦点停在上面,浏览器默认会拿方向键
|
||||||
|
// 切换选项 —— 老师想看下一条,结果模式被切了。单选框照常换条,下面的 preventDefault 挡掉切换
|
||||||
|
const radio = target.tagName === "INPUT" && (target as HTMLInputElement).type === "radio"
|
||||||
|
// 焦点在输入框、下拉框、弹框里时不抢方向键
|
||||||
if (
|
if (
|
||||||
target &&
|
(!radio && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) ||
|
||||||
(target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.isContentEditable ||
|
target.isContentEditable ||
|
||||||
target.closest(".n-base-select-menu, .n-dropdown-menu, .n-modal"))
|
target.closest(".n-base-selection, .n-base-select-menu, .n-dropdown-menu, .n-modal")
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!rows.value.length) return
|
if (!rows.value.length) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
move(e.key === "ArrowUp" ? -1 : 1)
|
move(e.key === "ArrowUp" ? -1 : 1)
|
||||||
@@ -613,6 +659,16 @@ function dayBreak(index: number) {
|
|||||||
<span v-if="userStore.isAuthed" class="vsep"></span>
|
<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">
|
<template v-if="teacher && !inContest && !mine">
|
||||||
<button
|
<button
|
||||||
class="lesson"
|
class="lesson"
|
||||||
@@ -741,6 +797,12 @@ function dayBreak(index: number) {
|
|||||||
size="small"
|
size="small"
|
||||||
:options="flowMode ? gradeOptions : resultOptions"
|
:options="flowMode ? gradeOptions : resultOptions"
|
||||||
/>
|
/>
|
||||||
|
<n-select
|
||||||
|
v-if="!inContest && !flowMode"
|
||||||
|
v-model:value="query.language"
|
||||||
|
size="small"
|
||||||
|
:options="languageOptions"
|
||||||
|
/>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<button v-if="!inContest" class="chip" :class="{ on: today }" @click="today = !today">
|
<button v-if="!inContest" class="chip" :class="{ on: today }" @click="today = !today">
|
||||||
<Icon v-if="today" icon="ph:check-bold" :width="12" />今天
|
<Icon v-if="today" icon="ph:check-bold" :width="12" />今天
|
||||||
@@ -762,18 +824,39 @@ function dayBreak(index: number) {
|
|||||||
<div v-if="dayBreak(index)" class="day">{{ dayBreak(index) }}</div>
|
<div v-if="dayBreak(index)" class="day">{{ dayBreak(index) }}</div>
|
||||||
<div
|
<div
|
||||||
class="row"
|
class="row"
|
||||||
:class="{ on: row.id === selectedId && !isMobile }"
|
:class="{ on: row.id === selectedId && !isMobile, mobile: isMobile }"
|
||||||
:data-row-id="row.id"
|
:data-row-id="row.id"
|
||||||
@click="rowClicked(row)"
|
@click="rowClicked(row)"
|
||||||
>
|
>
|
||||||
|
<template v-if="isDesktop">
|
||||||
<span class="c-time">{{ submissionClockText(row.createTime) }}</span>
|
<span class="c-time">{{ submissionClockText(row.createTime) }}</span>
|
||||||
<span class="c-user"><UserName :username="row.username" /></span>
|
<span class="c-user"><UserName :username="row.username" /></span>
|
||||||
<span class="c-problem">
|
<span class="c-problem">
|
||||||
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
|
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
|
||||||
</span>
|
</span>
|
||||||
<span class="c-state">
|
<span class="c-state">
|
||||||
<FlowchartState :status="row.status" :grade="row.aiGrade" :score="row.aiScore" />
|
<FlowchartState
|
||||||
|
:status="row.status"
|
||||||
|
:grade="row.aiGrade"
|
||||||
|
:score="row.aiScore"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="m-line">
|
||||||
|
<UserName :username="row.username" />
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<FlowchartState
|
||||||
|
:status="row.status"
|
||||||
|
:grade="row.aiGrade"
|
||||||
|
:score="row.aiScore"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="m-line sub">
|
||||||
|
<span>{{ submissionClockText(row.createTime) }}</span>
|
||||||
|
<span class="title">{{ row.problemDisplayId }} {{ row.problemTitle }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -815,12 +898,14 @@ function dayBreak(index: number) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="c-state"><StatusPill :result="row.result" /></span>
|
<span class="c-state"><StatusPill :result="row.result" /></span>
|
||||||
<span v-if="!teacher" class="c-lock">
|
<span v-if="!teacher" class="c-lock">
|
||||||
<Icon
|
<!-- title 放在外层 span:挂在 svg 上 Chrome 不出提示 -->
|
||||||
|
<span
|
||||||
v-if="!row.showLink"
|
v-if="!row.showLink"
|
||||||
icon="ph:lock-simple"
|
class="lock-hint"
|
||||||
:width="13"
|
|
||||||
:title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'"
|
:title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'"
|
||||||
/>
|
>
|
||||||
|
<Icon icon="ph:lock-simple" :width="13" />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -905,19 +990,25 @@ function dayBreak(index: number) {
|
|||||||
<FlowchartPane
|
<FlowchartPane
|
||||||
v-if="flowMode"
|
v-if="flowMode"
|
||||||
:row="selectedRow as FlowchartSubmissionListItem"
|
:row="selectedRow as FlowchartSubmissionListItem"
|
||||||
:teacher="false"
|
:teacher="teacher"
|
||||||
:position="null"
|
:position="null"
|
||||||
narrow
|
narrow
|
||||||
|
@filter-user="(name: string) => ((mobilePane = false), filterUser(name))"
|
||||||
|
@filter-problem="(id: string) => ((mobilePane = false), filterProblem(id))"
|
||||||
@open-problem="openProblem"
|
@open-problem="openProblem"
|
||||||
|
@retry="retryFlowchart"
|
||||||
/>
|
/>
|
||||||
<SubmissionPane
|
<SubmissionPane
|
||||||
v-else
|
v-else
|
||||||
:row="selectedRow as SubmissionListItem"
|
:row="selectedRow as SubmissionListItem"
|
||||||
:teacher="false"
|
:teacher="teacher"
|
||||||
:contest="inContest"
|
:contest="inContest"
|
||||||
:position="null"
|
:position="null"
|
||||||
narrow
|
narrow
|
||||||
|
@filter-user="(name: string) => ((mobilePane = false), filterUser(name))"
|
||||||
|
@filter-problem="(id: string) => ((mobilePane = false), filterProblem(id))"
|
||||||
@open-problem="openProblem"
|
@open-problem="openProblem"
|
||||||
|
@rejudge="rejudge"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 手机没有方向键:上一条 / 下一条换成按钮,走的是同一个 move() -->
|
<!-- 手机没有方向键:上一条 / 下一条换成按钮,走的是同一个 move() -->
|
||||||
@@ -932,7 +1023,12 @@ function dayBreak(index: number) {
|
|||||||
<span class="mobile-pos">{{
|
<span class="mobile-pos">{{
|
||||||
position ? `${position.index} / ${position.count}` : ""
|
position ? `${position.index} / ${position.count}` : ""
|
||||||
}}</span>
|
}}</span>
|
||||||
<n-button @click="move(1)">下一条</n-button>
|
<n-button
|
||||||
|
:disabled="!position || (position.index >= position.count && query.page >= maxPage)"
|
||||||
|
@click="move(1)"
|
||||||
|
>
|
||||||
|
下一条
|
||||||
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-drawer-content>
|
</n-drawer-content>
|
||||||
@@ -1157,6 +1253,11 @@ function dayBreak(index: number) {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 往上翻时选中那一行别被吸顶的日期行挡住 */
|
||||||
|
.row {
|
||||||
|
scroll-margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.row:hover {
|
.row:hover {
|
||||||
background: v-bind("theme.hoverColor");
|
background: v-bind("theme.hoverColor");
|
||||||
}
|
}
|
||||||
@@ -1271,6 +1372,10 @@ function dayBreak(index: number) {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lock-hint {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.c-lock {
|
.c-lock {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { onMounted, ref } from "vue"
|
import { onMounted, ref } from "vue"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计面板的「暂时隐藏某个学生」。
|
* 课堂看板的「请假隐藏」:暂时把某个学生从「今天还没交过」里藏起来。
|
||||||
*
|
*
|
||||||
* 老师是把面板投在屏幕上盯着看的,未完成名单里总有那么几个是请假/转班/学号错了的,
|
* 老师是把看板投在屏幕上盯着看的,没交名单里总有那么几个是请假/转班/学号错了的,
|
||||||
* 一直挂在上面会盖住真正需要盯的人。隐藏是**带过期时间**的(两小时,够一节课),
|
* 一直挂在上面会盖住真正需要盯的人。隐藏是**带过期时间**的(两小时,够一节课),
|
||||||
* 不是永久删除 —— 下节课自动回来,免得有人被无声地漏掉。
|
* 不是永久删除 —— 下节课自动回来,免得有人被无声地漏掉。
|
||||||
*
|
*
|
||||||
* 存在 localStorage 而不是后端:这是「这台电脑上这位老师这节课不想看谁」,
|
* 存在 localStorage 而不是后端:这是「这台电脑上这位老师这节课不想看谁」,
|
||||||
* 换个人、换台机器都不该继承。
|
* 换个人、换台机器都不该继承。
|
||||||
*
|
*
|
||||||
* 原来这套(loadHidden / saveHidden / hideStudent / showAll / 过期清理)在
|
* 原来在提交统计、流程图统计两个弹框里;统计改成回头看的页面之后(2026-09),
|
||||||
* StatisticsPanel.vue 和 FlowchartStatisticsPanel.vue 里各写了一遍,除了存储键
|
* 课上盯人的事归看板,这个也挪了过来。
|
||||||
* 和一个参数名逐字相同;判断「有没有被隐藏」两边还各自内联了三处。
|
|
||||||
*
|
*
|
||||||
* @param storageKey localStorage 的键。**两个面板各用各的** —— 提交统计里隐掉的人
|
* @param storageKey localStorage 的键
|
||||||
* 不该连带在流程图统计里也消失,那是两件事。
|
|
||||||
*/
|
*/
|
||||||
export function useHiddenStudents(storageKey: string) {
|
export function useHiddenStudents(storageKey: string) {
|
||||||
/** 隐藏时长:两小时,一节课的量级 */
|
/** 隐藏时长:两小时,一节课的量级 */
|
||||||
@@ -30,7 +28,7 @@ export function useHiddenStudents(storageKey: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hiddenStudents = ref<Record<string, number>>(load())
|
const hiddenStudents = ref<Record<string, number>>(load())
|
||||||
/** 面板上的「隐藏模式」开关:打开后每行才出现那个隐藏按钮 */
|
/** 「请假隐藏」开关:打开后名字旁边才出现那个 × */
|
||||||
const hideMode = ref(false)
|
const hideMode = ref(false)
|
||||||
|
|
||||||
function save(data: Record<string, number>) {
|
function save(data: Record<string, number>) {
|
||||||
@@ -45,9 +43,13 @@ export function useHiddenStudents(storageKey: string) {
|
|||||||
save(hiddenStudents.value)
|
save(hiddenStudents.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAll() {
|
/** 恢复这几个人。只恢复看板上这个班的,别把别的班藏着的人一起放出来 */
|
||||||
hiddenStudents.value = {}
|
function unhide(usernames: string[]) {
|
||||||
save({})
|
const drop = new Set(usernames)
|
||||||
|
hiddenStudents.value = Object.fromEntries(
|
||||||
|
Object.entries(hiddenStudents.value).filter(([name]) => !drop.has(name)),
|
||||||
|
)
|
||||||
|
save(hiddenStudents.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHidden(username: string) {
|
function isHidden(username: string) {
|
||||||
@@ -55,11 +57,6 @@ export function useHiddenStudents(storageKey: string) {
|
|||||||
return !!expiresAt && expiresAt > Date.now()
|
return !!expiresAt && expiresAt > Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 给 filter 用:`list.filter(notHidden)` */
|
|
||||||
function notHidden(item: { username: string }) {
|
|
||||||
return !isHidden(item.username)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 把已经到期的清掉再落一次盘,否则这张表只增不减
|
// 把已经到期的清掉再落一次盘,否则这张表只增不减
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -70,7 +67,7 @@ export function useHiddenStudents(storageKey: string) {
|
|||||||
save(cleaned)
|
save(cleaned)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 不导出 hiddenStudents 本身:两个面板要的都是「这个人该不该显示」,
|
// 不导出 hiddenStudents 本身:用的地方要的是「这个人该不该显示」,
|
||||||
// 把那张表递出去只会让判断逻辑又散回各自的组件里
|
// 把那张表递出去只会让判断逻辑又散回组件里
|
||||||
return { hideMode, hideStudent, showAll, isHidden, notHidden }
|
return { hideMode, hideStudent, unhide, isHidden }
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user