Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
530e1a3d5d
commit
195069450f
480 files changed
+15507
-24650
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { getSubmission } from "oj/api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import type { Submission } from "utils/types"
|
||||
import type { Tone } from "shared/composables/tone"
|
||||
import { getSubmission } from 'oj/api'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import type { Submission } from 'utils/types'
|
||||
import type { Tone } from 'shared/composables/tone'
|
||||
|
||||
/**
|
||||
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样:
|
||||
@@ -10,17 +10,17 @@ import type { Tone } from "shared/composables/tone"
|
||||
|
||||
/** 对了绿;编译失败、运行时错误、语法规则没过、部分通过橙;判题中蓝;其余红 */
|
||||
export function attemptTone(result: number): Tone {
|
||||
if (result === SubmissionStatus.accepted) return "success"
|
||||
if (result === SubmissionStatus.accepted) return 'success'
|
||||
if (
|
||||
result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error ||
|
||||
result === SubmissionStatus.ast_check_failed ||
|
||||
result === SubmissionStatus.partial_accepted
|
||||
) {
|
||||
return "warning"
|
||||
return 'warning'
|
||||
}
|
||||
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return "info"
|
||||
return "error"
|
||||
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return 'info'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Submission, SUBMISSION_RESULT } from "utils/types"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { attemptTone, cachedSubmission } from "../attempts"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { Submission, SUBMISSION_RESULT } from 'utils/types'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { attemptTone, cachedSubmission } from '../attempts'
|
||||
|
||||
/**
|
||||
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
|
||||
@@ -21,20 +21,20 @@ const props = withDefaults(
|
||||
const tone = useTone()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
|
||||
const code = reactive<Record<string, Submission | 'loading' | 'failed'>>({})
|
||||
|
||||
async function load(id: string) {
|
||||
if (code[id] && code[id] !== "failed") return
|
||||
code[id] = "loading"
|
||||
if (code[id] && code[id] !== 'failed') return
|
||||
code[id] = 'loading'
|
||||
try {
|
||||
code[id] = await cachedSubmission(id)
|
||||
} catch {
|
||||
code[id] = "failed"
|
||||
code[id] = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
function open(id: string) {
|
||||
window.open("/submission/" + id, "_blank", "noopener")
|
||||
window.open('/submission/' + id, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
const cells = computed(() =>
|
||||
@@ -48,13 +48,7 @@ const cells = computed(() =>
|
||||
|
||||
<template>
|
||||
<span class="squares">
|
||||
<n-popover
|
||||
v-for="cell in cells"
|
||||
:key="cell.id"
|
||||
:delay="200"
|
||||
:style="{ maxWidth: '560px' }"
|
||||
@update:show="(show: boolean) => show && load(cell.id)"
|
||||
>
|
||||
<n-popover v-for="cell in cells" :key="cell.id" :delay="200" :style="{ maxWidth: '560px' }" @update:show="(show: boolean) => show && load(cell.id)">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="square"
|
||||
@@ -66,9 +60,7 @@ const cells = computed(() =>
|
||||
<div class="pop">
|
||||
<div class="pop-head">
|
||||
<b>{{ JUDGE_STATUS[cell.result]?.name }}</b>
|
||||
<span class="muted"
|
||||
>{{ parseTime(cell.createTime, "M月D日 HH:mm") }} · 第 {{ cell.index }} 次</span
|
||||
>
|
||||
<span class="muted">{{ parseTime(cell.createTime, 'M月D日 HH:mm') }} · 第 {{ cell.index }} 次</span>
|
||||
</div>
|
||||
<n-code
|
||||
v-if="typeof code[cell.id] === 'object'"
|
||||
@@ -102,7 +94,7 @@ const cells = computed(() =>
|
||||
|
||||
.square:hover {
|
||||
/* button 的 color 是浏览器默认的黑,暗色下 currentColor 描边会看不见 */
|
||||
outline: 2px solid v-bind("theme.textColor2");
|
||||
outline: 2px solid v-bind('theme.textColor2');
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { NO_CLASS, type SubmissionStatisticsGrid } from "@oj2/contract"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { submissionDayText } from "oj/submission/utils"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { NO_CLASS, type SubmissionStatisticsGrid } from '@oj2/contract'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { submissionDayText } from 'oj/submission/utils'
|
||||
|
||||
/**
|
||||
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
|
||||
@@ -26,7 +26,7 @@ function isAc(result: number) {
|
||||
|
||||
const classes = computed(() => {
|
||||
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
|
||||
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
|
||||
const groups = new Map<string, SubmissionStatisticsGrid['rows']>()
|
||||
for (const row of props.grid.rows) {
|
||||
const key = row.className ?? NO_CLASS
|
||||
groups.set(key, [...(groups.get(key) ?? []), row])
|
||||
@@ -36,22 +36,15 @@ const classes = computed(() => {
|
||||
.map(([className, rows]) => {
|
||||
let submissions = 0
|
||||
let accepted = 0
|
||||
let last = ""
|
||||
let last = ''
|
||||
let done = 0
|
||||
for (const row of rows) {
|
||||
const solved = new Set(
|
||||
row.submissions
|
||||
.filter((item) => isAc(item.result))
|
||||
.map((item) => item.problemDisplayId),
|
||||
)
|
||||
const solved = new Set(row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId))
|
||||
if (props.explicit ? need.every((pid) => solved.has(pid)) : solved.size > 0) done++
|
||||
// 正确率的分母不算还在判的,和数字行一个口径
|
||||
submissions += row.submissions.filter(
|
||||
(item) =>
|
||||
item.result !== SubmissionStatus.pending && item.result !== SubmissionStatus.judging,
|
||||
).length
|
||||
submissions += row.submissions.filter((item) => item.result !== SubmissionStatus.pending && item.result !== SubmissionStatus.judging).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
|
||||
}
|
||||
const size = props.grid.classSizes[className] ?? rows.length
|
||||
@@ -62,7 +55,7 @@ const classes = computed(() => {
|
||||
done,
|
||||
tried: rows.length - done,
|
||||
none: Math.max(0, size - rows.length),
|
||||
rate: submissions ? `${Math.round((accepted / submissions) * 100)}%` : "—",
|
||||
rate: submissions ? `${Math.round((accepted / submissions) * 100)}%` : '—',
|
||||
last,
|
||||
triedUsers: rows.length,
|
||||
}
|
||||
@@ -78,8 +71,8 @@ const classes = computed(() => {
|
||||
<div class="by-class">
|
||||
<div class="cols">
|
||||
<span class="c-class">班级</span>
|
||||
<span class="c-done">{{ explicit ? "做完" : "做对过题" }}</span>
|
||||
<span class="c-n">{{ explicit ? "交了没对" : "一道没对" }}</span>
|
||||
<span class="c-done">{{ explicit ? '做完' : '做对过题' }}</span>
|
||||
<span class="c-n">{{ explicit ? '交了没对' : '一道没对' }}</span>
|
||||
<span class="c-n">没交</span>
|
||||
<span class="c-n">正确率</span>
|
||||
<span class="c-last">最近一次</span>
|
||||
@@ -90,21 +83,16 @@ const classes = computed(() => {
|
||||
<b class="c-class">{{ row.label }}</b>
|
||||
<span class="c-done">
|
||||
<span class="bar">
|
||||
<span
|
||||
class="fill"
|
||||
:style="{ width: `${row.size ? (row.done / row.size) * 100 : 0}%` }"
|
||||
></span>
|
||||
<span class="fill" :style="{ width: `${row.size ? (row.done / row.size) * 100 : 0}%` }"></span>
|
||||
</span>
|
||||
<span class="num">{{ row.done }}/{{ row.size }}</span>
|
||||
</span>
|
||||
<span class="c-n" :style="{ color: tone('warning').color }">{{ row.tried }}</span>
|
||||
<span class="c-n" :style="{ color: tone('error').color }">{{ row.none }}</span>
|
||||
<span class="c-n muted">{{ row.rate }}</span>
|
||||
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
|
||||
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : '—' }}</span>
|
||||
<span class="spacer"></span>
|
||||
<n-button size="small" text type="primary" @click="emit('pick', row.className)">
|
||||
看这个班 →
|
||||
</n-button>
|
||||
<n-button size="small" text type="primary" @click="emit('pick', row.className)"> 看这个班 → </n-button>
|
||||
</div>
|
||||
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
|
||||
<p v-if="grid.truncated" class="note warn" :style="{ color: tone('warning').color }">
|
||||
@@ -131,9 +119,9 @@ const classes = computed(() => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.rows {
|
||||
@@ -149,7 +137,7 @@ const classes = computed(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.c-class {
|
||||
@@ -179,14 +167,14 @@ const classes = computed(() => {
|
||||
width: 150px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
background: v-bind('theme.dividerColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
display: block;
|
||||
height: 6px;
|
||||
background: v-bind("theme.successColor");
|
||||
background: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.num {
|
||||
@@ -194,7 +182,7 @@ const classes = computed(() => {
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -204,14 +192,14 @@ const classes = computed(() => {
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 手机:这张表列多,横着滑 */
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { SubmissionStatistics, SubmissionStatisticsGrid } from "@oj2/contract"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import StatusPill from "oj/submission/components/StatusPill.vue"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import AttemptSquares from "./AttemptSquares.vue"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { SubmissionStatistics, SubmissionStatisticsGrid } from '@oj2/contract'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import StatusPill from 'oj/submission/components/StatusPill.vue'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import AttemptSquares from './AttemptSquares.vue'
|
||||
|
||||
/**
|
||||
* 代码统计的主体(设计稿「统计重设计 · 定稿 B」):左边题目,右边学生。
|
||||
@@ -31,7 +31,7 @@ const tone = useTone()
|
||||
// 暗色下「做对了」那格的底色是浅绿,白字看不清
|
||||
const isDark = useDark()
|
||||
|
||||
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
|
||||
type Attempt = SubmissionStatisticsGrid['rows'][number]['submissions'][number]
|
||||
|
||||
function isAc(result: number) {
|
||||
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
|
||||
@@ -98,10 +98,7 @@ function solvedOn(student: Student, pid: string) {
|
||||
*/
|
||||
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)
|
||||
)
|
||||
return items.some((item) => item.result === SubmissionStatus.ast_check_failed) && !items.some((item) => item.result === SubmissionStatus.accepted)
|
||||
}
|
||||
|
||||
function doneCount(student: Student) {
|
||||
@@ -118,7 +115,7 @@ const problemRows = computed(() =>
|
||||
...problem,
|
||||
done,
|
||||
tried: tried.length,
|
||||
avg: tried.length ? (attempts / tried.length).toFixed(1) : "—",
|
||||
avg: tried.length ? (attempts / tried.length).toFixed(1) : '—',
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -128,19 +125,19 @@ 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 doneLabel = computed(() => (props.explicit ? '做完' : '做对过题'))
|
||||
|
||||
const allDone = computed(() => students.value.filter(finished).length)
|
||||
|
||||
// ---------- 选中哪道题、筛哪些人 ----------
|
||||
|
||||
/** "" = 全部。只有一道题时直接选中它 */
|
||||
const focus = ref("")
|
||||
const focus = ref('')
|
||||
watch(
|
||||
problems,
|
||||
(list) => {
|
||||
if (list.length === 1) focus.value = list[0]!.problemDisplayId
|
||||
else if (!list.some((problem) => problem.problemDisplayId === focus.value)) focus.value = ""
|
||||
else if (!list.some((problem) => problem.problemDisplayId === focus.value)) focus.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -148,9 +145,7 @@ watch(
|
||||
/** 默认只看没做完的:复盘要找的是他们,做完的一长串沉在下面也看不见 */
|
||||
const onlyUnfinished = ref(true)
|
||||
|
||||
const focusRow = computed(() =>
|
||||
problemRows.value.find((row) => row.problemDisplayId === focus.value),
|
||||
)
|
||||
const focusRow = computed(() => problemRows.value.find((row) => row.problemDisplayId === focus.value))
|
||||
|
||||
/** 这道题上:没过的在前(交得越多越靠前 —— 卡得最久),一次没交的其次,做完的沉底 */
|
||||
const problemStudents = computed(() => {
|
||||
@@ -162,12 +157,7 @@ const problemStudents = computed(() => {
|
||||
const rank = (row: (typeof list)[number]) => (row.solved ? 2 : row.items.length ? 0 : 1)
|
||||
return list
|
||||
.filter((row) => !onlyUnfinished.value || !row.solved)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
b.items.length - a.items.length ||
|
||||
a.student.username.localeCompare(b.student.username, "zh-CN"),
|
||||
)
|
||||
.sort((a, b) => rank(a) - rank(b) || b.items.length - a.items.length || a.student.username.localeCompare(b.student.username, 'zh-CN'))
|
||||
})
|
||||
|
||||
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
|
||||
@@ -182,25 +172,18 @@ const allStudents = computed(() => {
|
||||
const rank = (row: (typeof list)[number]) => (row.finished ? 2 : row.student.total ? 0 : 1)
|
||||
return list
|
||||
.filter((row) => !onlyUnfinished.value || !row.finished)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
b.student.total - a.student.total ||
|
||||
a.student.username.localeCompare(b.student.username, "zh-CN"),
|
||||
)
|
||||
.sort((a, b) => rank(a) - rank(b) || b.student.total - a.student.total || a.student.username.localeCompare(b.student.username, 'zh-CN'))
|
||||
})
|
||||
|
||||
const unfinishedCount = computed(() =>
|
||||
focus.value
|
||||
? students.value.filter((student) => !solvedOn(student, focus.value)).length
|
||||
: students.value.filter((student) => !finished(student)).length,
|
||||
focus.value ? students.value.filter((student) => !solvedOn(student, focus.value)).length : students.value.filter((student) => !finished(student)).length,
|
||||
)
|
||||
|
||||
const expanded = ref("")
|
||||
watch([focus, onlyUnfinished], () => (expanded.value = ""))
|
||||
const expanded = ref('')
|
||||
watch([focus, onlyUnfinished], () => (expanded.value = ''))
|
||||
|
||||
function toggle(username: string) {
|
||||
expanded.value = expanded.value === username ? "" : username
|
||||
expanded.value = expanded.value === username ? '' : username
|
||||
}
|
||||
|
||||
/** 展开一个人:没过的题在前、交得多的更前 */
|
||||
@@ -209,23 +192,20 @@ function problemsOf(student: Student) {
|
||||
const sa = solvedOn(student, a.problemDisplayId)
|
||||
const sb = solvedOn(student, b.problemDisplayId)
|
||||
if (sa !== sb) return sa ? 1 : -1
|
||||
return (
|
||||
(student.byProblem.get(b.problemDisplayId)?.length ?? 0) -
|
||||
(student.byProblem.get(a.problemDisplayId)?.length ?? 0)
|
||||
)
|
||||
return (student.byProblem.get(b.problemDisplayId)?.length ?? 0) - (student.byProblem.get(a.problemDisplayId)?.length ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
function rate(student: Student) {
|
||||
return student.judged ? `${Math.round((student.accepted / student.judged) * 100)}%` : "—"
|
||||
return student.judged ? `${Math.round((student.accepted / student.judged) * 100)}%` : '—'
|
||||
}
|
||||
|
||||
function dayText(time: string | null) {
|
||||
return time ? parseTime(time, "M月D日 HH:mm") : "—"
|
||||
return time ? parseTime(time, 'M月D日 HH:mm') : '—'
|
||||
}
|
||||
|
||||
function pct(done: number, total: number) {
|
||||
return total > 0 ? `${Math.min(100, (done / total) * 100)}%` : "0%"
|
||||
return total > 0 ? `${Math.min(100, (done / total) * 100)}%` : '0%'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -235,20 +215,13 @@ function pct(done: number, total: number) {
|
||||
<section class="problems">
|
||||
<div class="col-head">题目(按你填的顺序)</div>
|
||||
<div class="problem-list">
|
||||
<button
|
||||
v-if="problems.length > 1"
|
||||
class="problem"
|
||||
:class="{ on: focus === '' }"
|
||||
@click="focus = ''"
|
||||
>
|
||||
<button v-if="problems.length > 1" class="problem" :class="{ on: focus === '' }" @click="focus = ''">
|
||||
<span class="p-line">
|
||||
<b>全部 {{ problems.length }} 道</b>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">{{ doneLabel }} {{ allDone }}/{{ personCount }} 人</span>
|
||||
</span>
|
||||
<span class="bar"
|
||||
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
|
||||
></span>
|
||||
<span class="bar"><span class="fill" :style="{ width: pct(allDone, personCount) }"></span></span>
|
||||
</button>
|
||||
<button
|
||||
v-for="row in problemRows"
|
||||
@@ -263,9 +236,7 @@ function pct(done: number, total: number) {
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">做完 {{ row.done }}/{{ personCount }} · 平均 {{ row.avg }} 次</span>
|
||||
</span>
|
||||
<span class="bar"
|
||||
><span class="fill" :style="{ width: pct(row.done, personCount) }"></span
|
||||
></span>
|
||||
<span class="bar"><span class="fill" :style="{ width: pct(row.done, personCount) }"></span></span>
|
||||
</button>
|
||||
<div v-if="!problems.length" class="empty">这段时间没有人交</div>
|
||||
</div>
|
||||
@@ -277,8 +248,7 @@ function pct(done: number, total: number) {
|
||||
<template v-if="focusRow">
|
||||
<b class="s-title">{{ focusRow.problemDisplayId }} {{ focusRow.title }}</b>
|
||||
<span class="muted">
|
||||
做完 {{ focusRow.done }}/{{ personCount }} · 交了没对
|
||||
{{ focusRow.tried - focusRow.done }} · 没交
|
||||
做完 {{ focusRow.done }}/{{ personCount }} · 交了没对 {{ focusRow.tried - focusRow.done }} · 没交
|
||||
{{ Math.max(0, personCount - focusRow.tried) }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -289,26 +259,15 @@ function pct(done: number, total: number) {
|
||||
<template v-else>
|
||||
<b class="s-title">全部 {{ problems.length }} 道</b>
|
||||
<span class="muted">
|
||||
{{ doneLabel }} {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} ·
|
||||
一道没交
|
||||
{{ doneLabel }} {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} · 一道没交
|
||||
{{ stats.dataUnaccepted.length }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
v-if="problems.length"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
v-if="problems.length"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -329,7 +288,7 @@ function pct(done: number, total: number) {
|
||||
<StatusPill v-if="row.items.length" :result="row.items.at(-1)!.result" />
|
||||
<b v-else class="none" :style="{ color: tone('error').color }">没交</b>
|
||||
</span>
|
||||
<span class="c-n muted">{{ row.items.length || "—" }}</span>
|
||||
<span class="c-n muted">{{ row.items.length || '—' }}</span>
|
||||
<span class="c-seq"><AttemptSquares :items="row.items" /></span>
|
||||
<span class="c-when muted">{{ dayText(row.items.at(-1)?.createTime ?? null) }}</span>
|
||||
</div>
|
||||
@@ -345,7 +304,7 @@ function pct(done: number, total: number) {
|
||||
<span class="c-done">做完</span>
|
||||
<span class="c-n">提交</span>
|
||||
<span class="c-rate">正确率</span>
|
||||
<span class="c-grid">{{ problems.map((p) => p.problemDisplayId).join(" · ") }}</span>
|
||||
<span class="c-grid">{{ problems.map((p) => p.problemDisplayId).join(' · ') }}</span>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<template v-for="row in allStudents" :key="row.student.username">
|
||||
@@ -360,22 +319,13 @@ function pct(done: number, total: number) {
|
||||
@keydown.space.prevent="toggle(row.student.username)"
|
||||
>
|
||||
<span class="c-caret">
|
||||
<Icon
|
||||
:icon="
|
||||
expanded === row.student.username ? 'ph:caret-down-bold' : 'ph:caret-right-bold'
|
||||
"
|
||||
:width="12"
|
||||
/>
|
||||
<Icon :icon="expanded === row.student.username ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="12" />
|
||||
</span>
|
||||
<UserName :username="row.student.username" class="c-name" />
|
||||
<span
|
||||
class="c-done done"
|
||||
:style="{
|
||||
color: row.finished
|
||||
? tone('success').color
|
||||
: row.student.total
|
||||
? theme.textColor1
|
||||
: tone('error').color,
|
||||
color: row.finished ? tone('success').color : row.student.total ? theme.textColor1 : tone('error').color,
|
||||
}"
|
||||
>
|
||||
{{ explicit ? `${row.done}/${problems.length}` : `${row.done} 道` }}
|
||||
@@ -388,12 +338,7 @@ function pct(done: number, total: number) {
|
||||
:key="problem.problemDisplayId"
|
||||
class="cell"
|
||||
:title="
|
||||
problem.problemDisplayId +
|
||||
' ' +
|
||||
problem.title +
|
||||
(astOnlyOn(row.student, problem.problemDisplayId)
|
||||
? ':答案对了,但没按要求的写法写'
|
||||
: '')
|
||||
problem.problemDisplayId + ' ' + problem.title + (astOnlyOn(row.student, problem.problemDisplayId) ? ':答案对了,但没按要求的写法写' : '')
|
||||
"
|
||||
:style="
|
||||
astOnlyOn(row.student, problem.problemDisplayId)
|
||||
@@ -407,44 +352,29 @@ function pct(done: number, total: number) {
|
||||
>
|
||||
{{
|
||||
astOnlyOn(row.student, problem.problemDisplayId)
|
||||
? "语"
|
||||
? '语'
|
||||
: solvedOn(row.student, problem.problemDisplayId)
|
||||
? "✓"
|
||||
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
|
||||
? '✓'
|
||||
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? '·')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="expanded === row.student.username" class="detail">
|
||||
<div
|
||||
v-for="problem in problemsOf(row.student)"
|
||||
:key="problem.problemDisplayId"
|
||||
class="d-line"
|
||||
>
|
||||
<div v-for="problem in problemsOf(row.student)" :key="problem.problemDisplayId" class="d-line">
|
||||
<span class="d-title"
|
||||
><span class="muted">{{ problem.problemDisplayId }}</span>
|
||||
{{ problem.title }}</span
|
||||
><span class="muted">{{ problem.problemDisplayId }}</span> {{ problem.title }}</span
|
||||
>
|
||||
<span
|
||||
class="d-state"
|
||||
:style="{
|
||||
color: solvedOn(row.student, problem.problemDisplayId)
|
||||
? tone('success').color
|
||||
: tone('error').color,
|
||||
color: solvedOn(row.student, problem.problemDisplayId) ? tone('success').color : tone('error').color,
|
||||
}"
|
||||
>
|
||||
{{ row.student.byProblem.get(problem.problemDisplayId)?.length ?? 0 }} 次 ·
|
||||
{{
|
||||
solvedOn(row.student, problem.problemDisplayId)
|
||||
? "已通过"
|
||||
: row.student.byProblem.has(problem.problemDisplayId)
|
||||
? "还没过"
|
||||
: "没交"
|
||||
}}
|
||||
{{ solvedOn(row.student, problem.problemDisplayId) ? '已通过' : row.student.byProblem.has(problem.problemDisplayId) ? '还没过' : '没交' }}
|
||||
</span>
|
||||
<AttemptSquares
|
||||
:items="row.student.byProblem.get(problem.problemDisplayId) ?? []"
|
||||
/>
|
||||
<AttemptSquares :items="row.student.byProblem.get(problem.problemDisplayId) ?? []" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -452,8 +382,8 @@ function pct(done: number, total: number) {
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="grid.truncated" class="truncated">
|
||||
范围太大,只取了最近的 5000 条提交:上面一排数字是全部算的,左边每道题的「做完」、
|
||||
右边的名单和方块串只按这 5000 条算,更早交的人可能不在里面。缩短时间段就准了。
|
||||
范围太大,只取了最近的 5000 条提交:上面一排数字是全部算的,左边每道题的「做完」、 右边的名单和方块串只按这 5000
|
||||
条算,更早交的人可能不在里面。缩短时间段就准了。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -471,7 +401,7 @@ function pct(done: number, total: number) {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid v-bind("theme.dividerColor");
|
||||
border-right: 1px solid v-bind('theme.dividerColor');
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -483,9 +413,9 @@ function pct(done: number, total: number) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.problem-list {
|
||||
@@ -504,7 +434,7 @@ function pct(done: number, total: number) {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
@@ -513,7 +443,7 @@ function pct(done: number, total: number) {
|
||||
}
|
||||
|
||||
.problem:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.problem.on {
|
||||
@@ -537,7 +467,7 @@ function pct(done: number, total: number) {
|
||||
.bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
background: v-bind('theme.dividerColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -545,11 +475,11 @@ function pct(done: number, total: number) {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.successColor");
|
||||
background: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-size: var(--oj-fs-meta);
|
||||
}
|
||||
|
||||
@@ -573,7 +503,7 @@ function pct(done: number, total: number) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -584,17 +514,17 @@ function pct(done: number, total: number) {
|
||||
.chip {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
background: v-bind("tone('success').background");
|
||||
color: v-bind("tone('success').color");
|
||||
font-weight: 600;
|
||||
@@ -614,9 +544,9 @@ function pct(done: number, total: number) {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.rows {
|
||||
@@ -632,7 +562,7 @@ function pct(done: number, total: number) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.row.clickable {
|
||||
@@ -640,7 +570,7 @@ function pct(done: number, total: number) {
|
||||
}
|
||||
|
||||
.row.clickable:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.row.on {
|
||||
@@ -651,7 +581,7 @@ function pct(done: number, total: number) {
|
||||
width: 16px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
.c-name {
|
||||
width: 110px;
|
||||
@@ -717,8 +647,8 @@ function pct(done: number, total: number) {
|
||||
|
||||
.detail {
|
||||
padding: 6px 20px 8px 46px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.d-line {
|
||||
@@ -745,14 +675,14 @@ function pct(done: number, total: number) {
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.truncated {
|
||||
padding: 10px var(--oj-pad-x);
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("tone('warning').color");
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
/* 手机:题目在上、学生在下,整页滚动 */
|
||||
@@ -765,7 +695,7 @@ function pct(done: number, total: number) {
|
||||
.problems {
|
||||
width: 100%;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.problem-list,
|
||||
|
||||
@@ -11,17 +11,17 @@ import {
|
||||
PointElement,
|
||||
RadialLinearScale,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { Bar, Doughnut, Radar } from "vue-chartjs"
|
||||
import type { FlowchartStatistics } from "@oj2/contract"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { FLOWCHART_CRITERIA_ORDER } from "utils/constants"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import RingChart from "./RingChart.vue"
|
||||
import StatItem from "./StatItem.vue"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
} from 'chart.js'
|
||||
import { WordCloudController, WordElement } from 'chartjs-chart-wordcloud'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { Bar, Doughnut, Radar } from 'vue-chartjs'
|
||||
import type { FlowchartStatistics } from '@oj2/contract'
|
||||
import { useChartTheme } from 'shared/composables/chartTheme'
|
||||
import { FLOWCHART_CRITERIA_ORDER } from 'utils/constants'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import RingChart from './RingChart.vue'
|
||||
import StatItem from './StatItem.vue'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
@@ -48,12 +48,12 @@ const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const { chartKey } = useChartTheme()
|
||||
|
||||
const GRADES = ["S", "A", "B", "C"] as const
|
||||
const GRADES = ['S', 'A', 'B', 'C'] as const
|
||||
const gradeColor = computed(() => ({
|
||||
S: tone("success").solid,
|
||||
A: tone("info").solid,
|
||||
B: tone("warning").solid,
|
||||
C: tone("error").solid,
|
||||
S: tone('success').solid,
|
||||
A: tone('info').solid,
|
||||
B: tone('warning').solid,
|
||||
C: tone('error').solid,
|
||||
}))
|
||||
|
||||
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
|
||||
@@ -78,8 +78,8 @@ const gradeData = computed(() => ({
|
||||
const gradeOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "60%",
|
||||
plugins: { legend: { position: "right" as const } },
|
||||
cutout: '60%',
|
||||
plugins: { legend: { position: 'right' as const } },
|
||||
}
|
||||
|
||||
/** 四项按固定顺序(jsonb 不保留键序,逻辑正确性要在最前) */
|
||||
@@ -94,11 +94,11 @@ const radarData = computed(() => ({
|
||||
labels: criteria.value.map((item) => item.key),
|
||||
datasets: [
|
||||
{
|
||||
label: "平均得分率",
|
||||
label: '平均得分率',
|
||||
data: criteria.value.map((item) => Math.round((item.avg / (item.max || 1)) * 100)),
|
||||
backgroundColor: "rgba(32, 128, 240, 0.18)",
|
||||
borderColor: tone("info").solid,
|
||||
pointBackgroundColor: tone("info").solid,
|
||||
backgroundColor: 'rgba(32, 128, 240, 0.18)',
|
||||
borderColor: tone('info').solid,
|
||||
pointBackgroundColor: tone('info').solid,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
@@ -120,24 +120,23 @@ const barData = computed(() => ({
|
||||
labels: criteria.value.map((item) => item.key),
|
||||
datasets: [
|
||||
{
|
||||
label: "平均分",
|
||||
label: '平均分',
|
||||
data: criteria.value.map((item) => item.avg),
|
||||
backgroundColor: tone("info").solid,
|
||||
backgroundColor: tone('info').solid,
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const barOptions = computed(() => ({
|
||||
indexAxis: "y" as const,
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: { dataIndex: number; parsed: { x: number | null } }) =>
|
||||
`${context.parsed.x ?? 0} / ${criteria.value[context.dataIndex]?.max ?? 100}`,
|
||||
label: (context: { dataIndex: number; parsed: { x: number | null } }) => `${context.parsed.x ?? 0} / ${criteria.value[context.dataIndex]?.max ?? 100}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -146,8 +145,8 @@ const barOptions = computed(() => ({
|
||||
|
||||
// ---------- 词云(chart.js 的 wordCloud 插件,canvas 上画,要自己管实例) ----------
|
||||
|
||||
const WORD_COLORS = ["#2080f0", "#18a058", "#d03050", "#f0a020", "#7a3e9d", "#13c2c2"]
|
||||
const cloudCanvas = useTemplateRef<HTMLCanvasElement>("cloudCanvas")
|
||||
const WORD_COLORS = ['#2080f0', '#18a058', '#d03050', '#f0a020', '#7a3e9d', '#13c2c2']
|
||||
const cloudCanvas = useTemplateRef<HTMLCanvasElement>('cloudCanvas')
|
||||
let cloud: ChartJS | null = null
|
||||
|
||||
function drawCloud() {
|
||||
@@ -157,12 +156,12 @@ function drawCloud() {
|
||||
if (!cloudCanvas.value || !words.length) return
|
||||
const max = Math.max(...words.map((word) => word.count))
|
||||
cloud = new ChartJS(cloudCanvas.value, {
|
||||
type: "wordCloud" as never,
|
||||
type: 'wordCloud' as never,
|
||||
data: {
|
||||
labels: words.map((word) => word.word),
|
||||
datasets: [
|
||||
{
|
||||
label: "",
|
||||
label: '',
|
||||
data: words.map((word) => 12 + (word.count / max) * 42),
|
||||
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
|
||||
rotate: 0,
|
||||
@@ -178,7 +177,7 @@ function drawCloud() {
|
||||
callbacks: {
|
||||
label: (context: { dataIndex: number }) => {
|
||||
const word = words[context.dataIndex]
|
||||
return word ? `${word.word}:${word.count} 次` : ""
|
||||
return word ? `${word.word}:${word.count} 次` : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -211,15 +210,13 @@ const people = computed(() => [
|
||||
|
||||
function gradeStyle(grade: string | null) {
|
||||
const map: Record<string, ReturnType<typeof tone>> = {
|
||||
S: tone("success"),
|
||||
A: tone("info"),
|
||||
B: tone("warning"),
|
||||
C: tone("error"),
|
||||
S: tone('success'),
|
||||
A: tone('info'),
|
||||
B: tone('warning'),
|
||||
C: tone('error'),
|
||||
}
|
||||
const t = grade ? map[grade] : null
|
||||
return t
|
||||
? { color: t.color, background: t.background }
|
||||
: { color: theme.value.textColor3, background: theme.value.actionColor }
|
||||
return t ? { color: t.color, background: t.background } : { color: theme.value.textColor3, background: theme.value.actionColor }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -227,35 +224,17 @@ function gradeStyle(grade: string | null) {
|
||||
<div class="flow-stats">
|
||||
<div class="summary">
|
||||
<StatItem label="提交" :value="data.totalCount" unit="张" />
|
||||
<StatItem
|
||||
label="拿到 A / S"
|
||||
:value="`${goodCount} / ${personCount}`"
|
||||
unit="人"
|
||||
tone="success"
|
||||
/>
|
||||
<StatItem
|
||||
label="一张没交"
|
||||
:value="data.dataUnaccepted.length"
|
||||
unit="人"
|
||||
tone="error"
|
||||
:hidden="!data.personCount"
|
||||
/>
|
||||
<StatItem label="拿到 A / S" :value="`${goodCount} / ${personCount}`" unit="人" tone="success" />
|
||||
<StatItem label="一张没交" :value="data.dataUnaccepted.length" unit="人" tone="error" :hidden="!data.personCount" />
|
||||
<span class="spacer"></span>
|
||||
<!-- 没选班级就没有花名册,「班级完成度」无从谈起 -->
|
||||
<span v-if="data.personCount" class="ring-wrap">
|
||||
<RingChart
|
||||
:value="goodCount"
|
||||
:total="personCount"
|
||||
:color="tone('info').solid"
|
||||
:labels="['拿到 A / S', '还没有']"
|
||||
/>
|
||||
<RingChart :value="goodCount" :total="personCount" :color="tone('info').solid" :labels="['拿到 A / S', '还没有']" />
|
||||
<span class="ring-label">班级<br />完成度</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!data.totalCount && !data.dataUnaccepted.length" class="empty">
|
||||
这段时间没有人交流程图
|
||||
</div>
|
||||
<div v-if="!data.totalCount && !data.dataUnaccepted.length" class="empty">这段时间没有人交流程图</div>
|
||||
<div v-else class="body">
|
||||
<div class="row-cards">
|
||||
<div class="card grade">
|
||||
@@ -311,7 +290,7 @@ function gradeStyle(grade: string | null) {
|
||||
<div v-for="row in people" :key="row.username" class="person">
|
||||
<UserName :username="row.username" class="p-name" />
|
||||
<span class="grade-pill" :style="gradeStyle(row.grade)">
|
||||
{{ row.grade ? `${row.grade} 级` : "没交" }}
|
||||
{{ row.grade ? `${row.grade} 级` : '没交' }}
|
||||
</span>
|
||||
<span class="muted">{{ row.count }} 次</span>
|
||||
</div>
|
||||
@@ -338,8 +317,8 @@ function gradeStyle(grade: string | null) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -355,7 +334,7 @@ function gradeStyle(grade: string | null) {
|
||||
.ring-label {
|
||||
font-size: var(--oj-fs-meta);
|
||||
line-height: 1.5;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.body {
|
||||
@@ -382,9 +361,9 @@ function gradeStyle(grade: string | null) {
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 8px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -414,7 +393,7 @@ function gradeStyle(grade: string | null) {
|
||||
.card-title {
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.chart,
|
||||
@@ -435,7 +414,7 @@ function gradeStyle(grade: string | null) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
font-size: var(--oj-fs-sec);
|
||||
}
|
||||
|
||||
@@ -457,14 +436,14 @@ function gradeStyle(grade: string | null) {
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-size: var(--oj-fs-meta);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 60px 16px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 手机:卡片一张一行 */
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { SubmissionLessons } from "@oj2/contract"
|
||||
import { parseTime, zonedParts } from "utils/functions"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { resultName, useResultColor } from "../results"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { SubmissionLessons } from '@oj2/contract'
|
||||
import { parseTime, zonedParts } from 'utils/functions'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { resultName, useResultColor } from '../results'
|
||||
|
||||
type Lesson = SubmissionLessons["lessons"][number]
|
||||
type Scattered = SubmissionLessons["scattered"][number]
|
||||
type Lesson = SubmissionLessons['lessons'][number]
|
||||
type Scattered = SubmissionLessons['scattered'][number]
|
||||
|
||||
/**
|
||||
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
|
||||
@@ -37,13 +37,13 @@ const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const resultColor = useResultColor()
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
|
||||
function dayText(day: string) {
|
||||
const [y, m, d] = day.split("-").map(Number) as [number, number, number]
|
||||
const [y, m, d] = day.split('-').map(Number) as [number, number, number]
|
||||
const weekday = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]
|
||||
const today = parseTime(new Date(), "YYYY-MM-DD")
|
||||
const yesterday = parseTime(new Date(Date.now() - 86_400_000), "YYYY-MM-DD")
|
||||
const today = parseTime(new Date(), 'YYYY-MM-DD')
|
||||
const yesterday = parseTime(new Date(Date.now() - 86_400_000), 'YYYY-MM-DD')
|
||||
const date = `${m}月${d}日 ${weekday}`
|
||||
if (day === today) return `今天 · ${date}`
|
||||
if (day === yesterday) return `昨天 · ${date}`
|
||||
@@ -76,18 +76,16 @@ const lessonCount = computed(() => props.data.lessons.length)
|
||||
const liveLessons = computed(() => props.data.lessons.filter((lesson) => lesson.live))
|
||||
|
||||
function clock(lesson: Lesson) {
|
||||
return `${parseTime(lesson.start, "HH:mm")}–${parseTime(lesson.end, "HH:mm")}`
|
||||
return `${parseTime(lesson.start, 'HH:mm')}–${parseTime(lesson.end, 'HH:mm')}`
|
||||
}
|
||||
|
||||
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
|
||||
function attendance(lesson: Lesson) {
|
||||
return lesson.classSize >= lesson.userCount
|
||||
? `${lesson.userCount} / ${lesson.classSize} 人`
|
||||
: `${lesson.userCount} 人`
|
||||
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
|
||||
}
|
||||
|
||||
function problemText(lesson: Lesson) {
|
||||
return `${lesson.fromProblemSet ? "题单 " : ""}${lesson.problems.length} 道`
|
||||
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
|
||||
}
|
||||
|
||||
// ---------- 时间轴(只在今天以内) ----------
|
||||
@@ -119,7 +117,7 @@ function barStyle(lesson: Lesson) {
|
||||
return {
|
||||
left: axisLeft(from),
|
||||
width: `max(6px, ${((to - from) / (end - start)) * 100}%)`,
|
||||
background: lesson.live ? theme.value.successColor : "#3f5f87",
|
||||
background: lesson.live ? theme.value.successColor : '#3f5f87',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,46 +127,27 @@ const nowHour = computed(() => {
|
||||
})
|
||||
|
||||
function percent(value: number, total: number) {
|
||||
return total ? `${(value / total) * 100}%` : "0"
|
||||
return total ? `${(value / total) * 100}%` : '0'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lessons">
|
||||
<div
|
||||
v-if="liveLessons.length"
|
||||
class="live"
|
||||
:style="{ background: tone('success').background, color: tone('success').color }"
|
||||
>
|
||||
<span>{{ liveLessons.map((item) => classLabel(item.className)).join("、") }}正在上课</span>
|
||||
<div v-if="liveLessons.length" class="live" :style="{ background: tone('success').background, color: tone('success').color }">
|
||||
<span>{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课</span>
|
||||
<span class="spacer"></span>
|
||||
<a href="#" class="live-link" @click.prevent="router.push('/classroom')">
|
||||
<Icon icon="ph:monitor" :width="14" />去课堂看板
|
||||
</a>
|
||||
<a href="#" class="live-link" @click.prevent="router.push('/classroom')"> <Icon icon="ph:monitor" :width="14" />去课堂看板 </a>
|
||||
</div>
|
||||
|
||||
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
|
||||
<div v-else-if="!lessonCount" class="note">
|
||||
这段时间没有哪个班一起上课(同一个班 5 人以上做同一道题才算一节课),只有零散提交
|
||||
</div>
|
||||
<div v-else-if="!lessonCount" class="note">这段时间没有哪个班一起上课(同一个班 5 人以上做同一道题才算一节课),只有零散提交</div>
|
||||
|
||||
<template v-for="group in days" :key="group.day">
|
||||
<div class="day">{{ group.label }}</div>
|
||||
<a
|
||||
v-for="lesson in group.lessons"
|
||||
:key="`${lesson.className}|${lesson.start}`"
|
||||
href="#"
|
||||
class="row"
|
||||
@click.prevent="emit('pick', lesson)"
|
||||
>
|
||||
<a v-for="lesson in group.lessons" :key="`${lesson.className}|${lesson.start}`" href="#" class="row" @click.prevent="emit('pick', lesson)">
|
||||
<span class="c-time">{{ clock(lesson) }}</span>
|
||||
<span v-if="timeline" class="c-axis">
|
||||
<span
|
||||
v-for="tick in axis.ticks"
|
||||
:key="tick"
|
||||
class="grid"
|
||||
:style="{ left: axisLeft(tick) }"
|
||||
></span>
|
||||
<span v-for="tick in axis.ticks" :key="tick" class="grid" :style="{ left: axisLeft(tick) }"></span>
|
||||
<span class="bar" :style="barStyle(lesson)"></span>
|
||||
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
|
||||
</span>
|
||||
@@ -177,22 +156,20 @@ function percent(value: number, total: number) {
|
||||
<span class="c-probs">{{ problemText(lesson) }}</span>
|
||||
<span class="c-rate">
|
||||
{{ lesson.total }} 次 ·
|
||||
{{ lesson.live ? "上课中" : `正确率 ${Math.round(lesson.correctRate)}%` }}
|
||||
{{ lesson.live ? '上课中' : `正确率 ${Math.round(lesson.correctRate)}%` }}
|
||||
</span>
|
||||
<span class="c-hard">
|
||||
<template v-if="lesson.hardest">
|
||||
<n-text depth="3">错得最多</n-text>
|
||||
<n-text depth="3" class="pid">{{ lesson.hardest.problemDisplayId }}</n-text>
|
||||
{{ lesson.hardest.problemTitle }}
|
||||
<span :style="{ color: tone('warning').color }"
|
||||
>· 没过 {{ lesson.hardest.failed }} 次</span
|
||||
>
|
||||
<span :style="{ color: tone('warning').color }">· 没过 {{ lesson.hardest.failed }} 次</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
|
||||
</a>
|
||||
<div v-if="group.scattered.length" class="scattered">
|
||||
{{ group.lessons.length ? "另有零散提交:" : "零散提交:" }}
|
||||
{{ group.lessons.length ? '另有零散提交:' : '零散提交:' }}
|
||||
<template v-for="(item, index) in group.scattered" :key="item.className ?? ''">
|
||||
<template v-if="index"> · </template>
|
||||
<a href="#" title="看这个班这一天的提交" @click.prevent="emit('scattered', item)"
|
||||
@@ -207,23 +184,16 @@ function percent(value: number, total: number) {
|
||||
<span class="c-axis axis">
|
||||
<!-- 挨着「现在」的刻度不写,不然两个字叠在一起 -->
|
||||
<template v-for="tick in axis.ticks" :key="tick">
|
||||
<span
|
||||
v-if="nowHour === null || Math.abs(tick - nowHour) > 0.8"
|
||||
:style="{ left: axisLeft(tick) }"
|
||||
>
|
||||
<span v-if="nowHour === null || Math.abs(tick - nowHour) > 0.8" :style="{ left: axisLeft(tick) }">
|
||||
{{ tick }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="nowHour !== null" class="now-label" :style="{ left: axisLeft(nowHour) }">
|
||||
现在
|
||||
</span>
|
||||
<span v-if="nowHour !== null" class="now-label" :style="{ left: axisLeft(nowHour) }"> 现在 </span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.hasMore" class="more">
|
||||
<n-button size="small" :loading="loadingMore" @click="emit('more')">
|
||||
更早的课(现在列了最近 {{ lessonCount }} 节)
|
||||
</n-button>
|
||||
<n-button size="small" :loading="loadingMore" @click="emit('more')"> 更早的课(现在列了最近 {{ lessonCount }} 节) </n-button>
|
||||
</div>
|
||||
|
||||
<template v-if="data.hardProblems.length">
|
||||
@@ -254,9 +224,7 @@ function percent(value: number, total: number) {
|
||||
<td>{{ item.total }} 次</td>
|
||||
<td>
|
||||
<span class="mini">
|
||||
<span
|
||||
:style="{ width: percent(item.accepted, item.total), background: resultColor(0) }"
|
||||
></span>
|
||||
<span :style="{ width: percent(item.accepted, item.total), background: resultColor(0) }"></span>
|
||||
<span
|
||||
v-for="fail in item.failures"
|
||||
:key="fail.result"
|
||||
@@ -266,9 +234,7 @@ function percent(value: number, total: number) {
|
||||
}"
|
||||
></span>
|
||||
</span>
|
||||
<n-text v-if="item.failures[0]" depth="2" class="small">
|
||||
{{ resultName(item.failures[0].result) }} {{ item.failures[0].count }}
|
||||
</n-text>
|
||||
<n-text v-if="item.failures[0]" depth="2" class="small"> {{ resultName(item.failures[0].result) }} {{ item.failures[0].count }} </n-text>
|
||||
</td>
|
||||
<td
|
||||
class="right"
|
||||
@@ -321,13 +287,13 @@ function percent(value: number, total: number) {
|
||||
.empty {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 20px var(--oj-pad-x) 6px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.day {
|
||||
@@ -340,9 +306,9 @@ function percent(value: number, total: number) {
|
||||
padding: 0 var(--oj-pad-x);
|
||||
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");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.row {
|
||||
@@ -350,20 +316,20 @@ function percent(value: number, total: number) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--oj-pad-x);
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
color: v-bind("theme.textColor1");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
color: v-bind('theme.textColor1');
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.c-time {
|
||||
width: 110px;
|
||||
flex: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -384,13 +350,13 @@ function percent(value: number, total: number) {
|
||||
.c-probs {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.c-rate {
|
||||
width: 170px;
|
||||
flex: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.c-hard {
|
||||
@@ -412,7 +378,7 @@ function percent(value: number, total: number) {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -420,7 +386,7 @@ function percent(value: number, total: number) {
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
background: v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.bar {
|
||||
@@ -436,7 +402,7 @@ function percent(value: number, total: number) {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: v-bind("theme.successColor");
|
||||
background: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.axis-row {
|
||||
@@ -455,31 +421,31 @@ function percent(value: number, total: number) {
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.axis .now-label {
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.successColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind('theme.successColor');
|
||||
background: v-bind('theme.cardColor');
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.scattered {
|
||||
padding: 8px var(--oj-pad-x);
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.scattered a {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.scattered a:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.more {
|
||||
@@ -507,14 +473,14 @@ function percent(value: number, total: number) {
|
||||
.hard th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
padding: 4px 8px 6px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.hard td {
|
||||
padding: 8px 8px 8px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -524,7 +490,7 @@ function percent(value: number, total: number) {
|
||||
}
|
||||
|
||||
.hard-title a {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ArcElement, Chart as ChartJS, Tooltip } from "chart.js"
|
||||
import { Doughnut } from "vue-chartjs"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { ArcElement, Chart as ChartJS, Tooltip } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import { useChartTheme } from 'shared/composables/chartTheme'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip)
|
||||
|
||||
@@ -36,14 +36,13 @@ const data = computed(() => ({
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "72%",
|
||||
cutout: '72%',
|
||||
animation: false as const,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: { label: string; parsed: number }) =>
|
||||
`${context.label}:${context.parsed}`,
|
||||
label: (context: { label: string; parsed: number }) => `${context.label}:${context.parsed}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -53,7 +52,7 @@ const options = {
|
||||
<template>
|
||||
<span class="ring" :style="{ width: `${size ?? 48}px`, height: `${size ?? 48}px` }">
|
||||
<Doughnut :key="chartKey" :data="data" :options="options" />
|
||||
<span class="center">{{ total > 0 ? `${pct}%` : "—" }}</span>
|
||||
<span class="center">{{ total > 0 ? `${pct}%` : '—' }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone, type Tone } from "shared/composables/tone"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useTone, type Tone } from 'shared/composables/tone'
|
||||
|
||||
/** 数字行的一格:小字标签 + 大号数字 + 单位 */
|
||||
const props = defineProps<{
|
||||
@@ -38,7 +38,7 @@ const color = computed(() => (props.tone ? toneOf(props.tone).color : theme.valu
|
||||
.label,
|
||||
.unit {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.value-line {
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import {
|
||||
NO_CLASS,
|
||||
type FlowchartStatistics,
|
||||
type SubmissionLessons,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsGrid,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
getFlowchartStatistics,
|
||||
getSubmissionLessons,
|
||||
getSubmissionStatistics,
|
||||
getSubmissionStatisticsGrid,
|
||||
} from "oj/api"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { fromPickerValue, toPickerValue } from "utils/functions"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { classSelectOptions } from "utils/classes"
|
||||
import { DEFAULT_PERIOD, PERIOD_OPTIONS, dayStartOfDate, periodRange, type Period } from "../period"
|
||||
import { resultName, useResultColor } from "../results"
|
||||
import ByClass from "./ByClass.vue"
|
||||
import CodeStats from "./CodeStats.vue"
|
||||
import Lessons from "./Lessons.vue"
|
||||
import RingChart from "./RingChart.vue"
|
||||
import StatItem from "./StatItem.vue"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { NO_CLASS, type FlowchartStatistics, type SubmissionLessons, type SubmissionStatistics, type SubmissionStatisticsGrid } from '@oj2/contract'
|
||||
import { getFlowchartStatistics, getSubmissionLessons, getSubmissionStatistics, getSubmissionStatisticsGrid } from 'oj/api'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { fromPickerValue, toPickerValue } from 'utils/functions'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { classSelectOptions } from 'utils/classes'
|
||||
import { DEFAULT_PERIOD, PERIOD_OPTIONS, dayStartOfDate, periodRange, type Period } from '../period'
|
||||
import { resultName, useResultColor } from '../results'
|
||||
import ByClass from './ByClass.vue'
|
||||
import CodeStats from './CodeStats.vue'
|
||||
import Lessons from './Lessons.vue'
|
||||
import RingChart from './RingChart.vue'
|
||||
import StatItem from './StatItem.vue'
|
||||
|
||||
/**
|
||||
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
|
||||
@@ -43,7 +32,7 @@ import StatItem from "./StatItem.vue"
|
||||
*
|
||||
* 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。
|
||||
*/
|
||||
const FlowStats = defineAsyncComponent(() => import("./FlowStats.vue"))
|
||||
const FlowStats = defineAsyncComponent(() => import('./FlowStats.vue'))
|
||||
|
||||
const props = defineProps<{
|
||||
/** 在弹框里:右上角多一个关闭;「在提交列表里看」由外面接住,改的是背后那张列表 */
|
||||
@@ -61,13 +50,13 @@ const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const resultColor = useResultColor()
|
||||
|
||||
const tab = defineModel<string>("tab", { default: "code" })
|
||||
const className = defineModel<string>("className", { default: "" })
|
||||
const username = defineModel<string>("username", { default: "" })
|
||||
const problem = defineModel<string>("problem", { default: "" })
|
||||
const period = defineModel<string>("period", { default: DEFAULT_PERIOD })
|
||||
const from = defineModel<string>("from", { default: "" })
|
||||
const to = defineModel<string>("to", { default: "" })
|
||||
const tab = defineModel<string>('tab', { default: 'code' })
|
||||
const className = defineModel<string>('className', { default: '' })
|
||||
const username = defineModel<string>('username', { default: '' })
|
||||
const problem = defineModel<string>('problem', { default: '' })
|
||||
const period = defineModel<string>('period', { default: DEFAULT_PERIOD })
|
||||
const from = defineModel<string>('from', { default: '' })
|
||||
const to = defineModel<string>('to', { default: '' })
|
||||
|
||||
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
|
||||
const custom = computed<[number, number] | null>(() => {
|
||||
@@ -78,26 +67,22 @@ const custom = computed<[number, number] | null>(() => {
|
||||
|
||||
const periodValue = computed<Period>(() => {
|
||||
// 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认
|
||||
if (period.value === "lesson") return custom.value ? "lesson" : DEFAULT_PERIOD
|
||||
return PERIOD_OPTIONS.some((option) => option.value === period.value)
|
||||
? (period.value as Period)
|
||||
: DEFAULT_PERIOD
|
||||
if (period.value === 'lesson') return custom.value ? 'lesson' : DEFAULT_PERIOD
|
||||
return PERIOD_OPTIONS.some((option) => option.value === period.value) ? (period.value as Period) : DEFAULT_PERIOD
|
||||
})
|
||||
|
||||
/** 从「一行一节课」点进某一节课时,下拉里临时多一项「那节课」 */
|
||||
const periodOptions = computed(() => [
|
||||
...(periodValue.value === "lesson" ? [{ label: "那节课", value: "lesson" }] : []),
|
||||
...(periodValue.value === 'lesson' ? [{ label: '那节课', value: 'lesson' }] : []),
|
||||
...PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value as string })),
|
||||
])
|
||||
|
||||
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
||||
const pickerValue = computed<[number, number] | null>(() =>
|
||||
custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null,
|
||||
)
|
||||
const pickerValue = computed<[number, number] | null>(() => (custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null))
|
||||
function pickDates(value: [number, number] | null) {
|
||||
if (!value) {
|
||||
from.value = ""
|
||||
to.value = ""
|
||||
from.value = ''
|
||||
to.value = ''
|
||||
return
|
||||
}
|
||||
from.value = String(fromPickerValue(value[0]))
|
||||
@@ -107,24 +92,20 @@ function pickDates(value: [number, number] | null) {
|
||||
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
|
||||
const studentParam = computed(() => username.value.trim())
|
||||
|
||||
const classOptions = computed(() =>
|
||||
classSelectOptions(configStore.config?.classList ?? [], className.value),
|
||||
)
|
||||
const classOptions = computed(() => classSelectOptions(configStore.config?.classList ?? [], className.value))
|
||||
|
||||
// ---------- 取数 ----------
|
||||
|
||||
const loading = ref(false)
|
||||
const failed = ref("")
|
||||
const failed = ref('')
|
||||
const stats = ref<SubmissionStatistics | null>(null)
|
||||
const grid = ref<SubmissionStatisticsGrid | null>(null)
|
||||
const lessons = ref<SubmissionLessons | null>(null)
|
||||
const flow = ref<FlowchartStatistics | null>(null)
|
||||
|
||||
type Mode = "lessons" | "byClass" | "people"
|
||||
type Mode = 'lessons' | 'byClass' | 'people'
|
||||
/** 三种看法,见顶上的注释 */
|
||||
const mode = computed<Mode>(() =>
|
||||
className.value || studentParam.value ? "people" : problem.value.trim() ? "byClass" : "lessons",
|
||||
)
|
||||
const mode = computed<Mode>(() => (className.value || studentParam.value ? 'people' : problem.value.trim() ? 'byClass' : 'lessons'))
|
||||
|
||||
/** 一次列几节课,「更早的课」每点一次多 50 节 */
|
||||
const LESSON_PAGE = 50
|
||||
@@ -137,16 +118,16 @@ const loadingMore = ref(false)
|
||||
* 上一次的,标签先变了就对不上
|
||||
*/
|
||||
const shown = ref<{ mode: Mode; explicit: boolean; rangeText: string; withinToday: boolean }>({
|
||||
mode: "lessons",
|
||||
mode: 'lessons',
|
||||
explicit: false,
|
||||
rangeText: "",
|
||||
rangeText: '',
|
||||
withinToday: false,
|
||||
})
|
||||
let seq = 0
|
||||
|
||||
const ERROR_TEXT: Record<string, string> = {
|
||||
"problem-not-found": "有题号找不到,检查一下是不是打错了",
|
||||
"invalid-request": "一次最多查 20 道题",
|
||||
'problem-not-found': '有题号找不到,检查一下是不是打错了',
|
||||
'invalid-request': '一次最多查 20 道题',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -160,18 +141,13 @@ async function load() {
|
||||
withinToday: range.withinToday,
|
||||
}
|
||||
loading.value = true
|
||||
failed.value = ""
|
||||
failed.value = ''
|
||||
try {
|
||||
if (tab.value === "flow") {
|
||||
const res = await getFlowchartStatistics(
|
||||
duration,
|
||||
problem.value,
|
||||
studentParam.value,
|
||||
className.value,
|
||||
)
|
||||
if (tab.value === 'flow') {
|
||||
const res = await getFlowchartStatistics(duration, problem.value, studentParam.value, className.value)
|
||||
if (current !== seq) return
|
||||
flow.value = res
|
||||
} else if (snapshot.mode === "lessons") {
|
||||
} else if (snapshot.mode === 'lessons') {
|
||||
// 一行一节课不要方块串:那个接口最多 5000 条,课在后端切
|
||||
lessonLimit.value = LESSON_PAGE
|
||||
const [s, l] = await Promise.all([
|
||||
@@ -198,8 +174,8 @@ async function load() {
|
||||
grid.value = null
|
||||
lessons.value = null
|
||||
flow.value = null
|
||||
const code = (error as { error?: string })?.error ?? ""
|
||||
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
|
||||
const code = (error as { error?: string })?.error ?? ''
|
||||
failed.value = ERROR_TEXT[code] ?? '没拉下来,改一下条件再试'
|
||||
} finally {
|
||||
if (current === seq) loading.value = false
|
||||
}
|
||||
@@ -211,15 +187,12 @@ async function loadMore() {
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const range = periodRange(periodValue.value, custom.value)
|
||||
const next = await getSubmissionLessons(
|
||||
{ start: range.start, end: range.end },
|
||||
lessonLimit.value + LESSON_PAGE,
|
||||
)
|
||||
const next = await getSubmissionLessons({ start: range.start, end: range.end }, lessonLimit.value + LESSON_PAGE)
|
||||
if (current !== seq) return
|
||||
lessonLimit.value += LESSON_PAGE
|
||||
lessons.value = next
|
||||
} catch {
|
||||
if (current === seq) failed.value = "更早的课没拉下来,再点一次试试"
|
||||
if (current === seq) failed.value = '更早的课没拉下来,再点一次试试'
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
@@ -238,9 +211,7 @@ const doneCount = computed(() => stats.value?.data.filter((row) => row.done).len
|
||||
*/
|
||||
const hasProblems = computed(() => shown.value.explicit)
|
||||
const personCount = computed(() => stats.value?.personCount ?? 0)
|
||||
const judged = computed(() =>
|
||||
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
||||
)
|
||||
const judged = computed(() => (stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0))
|
||||
|
||||
// ---------- 右上角的入口 ----------
|
||||
|
||||
@@ -250,21 +221,21 @@ const judged = computed(() =>
|
||||
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的),
|
||||
* 现在只在有班正在上课时,在课的列表上方引过去
|
||||
*/
|
||||
const narrow = useMediaQuery(props.modal ? "(max-width: 1439px)" : "(max-width: 1365px)")
|
||||
const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)')
|
||||
|
||||
function openList() {
|
||||
emit("list", {
|
||||
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
|
||||
emit('list', {
|
||||
...(tab.value === 'flow' ? { language: 'Flowchart' } : {}),
|
||||
...(className.value ? { className: className.value } : {}),
|
||||
...(studentParam.value ? { username: studentParam.value } : {}),
|
||||
...(problem.value ? { problem: problem.value } : {}),
|
||||
...(shown.value.withinToday ? { today: "1" } : {}),
|
||||
...(shown.value.withinToday ? { today: '1' } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 点进去 / 回来 ----------
|
||||
|
||||
type Snapshot = Record<"className" | "username" | "problem" | "period" | "from" | "to", string>
|
||||
type Snapshot = Record<'className' | 'username' | 'problem' | 'period' | 'from' | 'to', string>
|
||||
/** 从「一行一节课」或「按班级汇总」点进去之前的条件,「← 全部」拿它回去 */
|
||||
const backTo = ref<Snapshot | null>(null)
|
||||
|
||||
@@ -293,36 +264,36 @@ function goBack() {
|
||||
|
||||
// 自己把条件清回了总览,就不用再显示「← 全部」
|
||||
watch(mode, (value) => {
|
||||
if (value === "lessons") backTo.value = null
|
||||
if (value === 'lessons') backTo.value = null
|
||||
})
|
||||
|
||||
function pickClass(value: string) {
|
||||
remember()
|
||||
className.value = value
|
||||
username.value = ""
|
||||
username.value = ''
|
||||
}
|
||||
|
||||
/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */
|
||||
function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
|
||||
function pickLesson(lesson: SubmissionLessons['lessons'][number]) {
|
||||
remember()
|
||||
className.value = lesson.className ?? NO_CLASS
|
||||
username.value = ""
|
||||
problem.value = lesson.problems.join(" ")
|
||||
username.value = ''
|
||||
problem.value = lesson.problems.join(' ')
|
||||
from.value = String(Date.parse(lesson.start))
|
||||
to.value = String(Date.parse(lesson.end) + 1000)
|
||||
period.value = "lesson"
|
||||
period.value = 'lesson'
|
||||
}
|
||||
|
||||
/** 点某天的零散提交:这个班(或没填班级的)那一整天,题号不限 —— 补做、课后练的都在里面 */
|
||||
function pickScattered(item: SubmissionLessons["scattered"][number]) {
|
||||
function pickScattered(item: SubmissionLessons['scattered'][number]) {
|
||||
remember()
|
||||
className.value = item.className ?? NO_CLASS
|
||||
username.value = ""
|
||||
problem.value = ""
|
||||
username.value = ''
|
||||
problem.value = ''
|
||||
const day = String(dayStartOfDate(item.day))
|
||||
from.value = day
|
||||
to.value = day
|
||||
period.value = "custom"
|
||||
period.value = 'custom'
|
||||
}
|
||||
|
||||
/** 点「错得最多的题」里的一道:只看这道,按班级汇总 */
|
||||
@@ -336,7 +307,7 @@ function pickProblem(displayId: string) {
|
||||
const results = computed(() => stats.value?.results ?? [])
|
||||
function resultWidth(value: number) {
|
||||
const total = stats.value?.submissionCount ?? 0
|
||||
return total ? `${(value / total) * 100}%` : "0"
|
||||
return total ? `${(value / total) * 100}%` : '0'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -344,9 +315,7 @@ function resultWidth(value: number) {
|
||||
<div class="stats" :class="{ modal }">
|
||||
<div class="filters">
|
||||
<span class="title">统计</span>
|
||||
<button v-if="backTo" type="button" class="back" title="回到点进来之前" @click="goBack">
|
||||
<Icon icon="ph:caret-left-bold" :width="13" />全部
|
||||
</button>
|
||||
<button v-if="backTo" type="button" class="back" title="回到点进来之前" @click="goBack"><Icon icon="ph:caret-left-bold" :width="13" />全部</button>
|
||||
<n-radio-group v-model:value="tab" size="small">
|
||||
<n-radio-button value="code">代码</n-radio-button>
|
||||
<n-radio-button value="flow">流程图</n-radio-button>
|
||||
@@ -365,13 +334,7 @@ function resultWidth(value: number) {
|
||||
<n-input v-model:value="username" class="w-user" size="small" clearable placeholder="学生">
|
||||
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
|
||||
</n-input>
|
||||
<n-input
|
||||
v-model:value="problem"
|
||||
class="w-problem"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="题号,几道用空格隔开"
|
||||
>
|
||||
<n-input v-model:value="problem" class="w-problem" size="small" clearable placeholder="题号,几道用空格隔开">
|
||||
<template #prefix><Icon icon="ph:hash" /></template>
|
||||
</n-input>
|
||||
<n-select
|
||||
@@ -382,21 +345,11 @@ function resultWidth(value: number) {
|
||||
:consistent-menu-width="false"
|
||||
@update:value="(v: string) => (period = v)"
|
||||
/>
|
||||
<n-date-picker
|
||||
v-if="periodValue === 'custom'"
|
||||
:value="pickerValue"
|
||||
type="daterange"
|
||||
size="small"
|
||||
class="w-dates"
|
||||
clearable
|
||||
@update:value="pickDates"
|
||||
/>
|
||||
<n-date-picker v-if="periodValue === 'custom'" :value="pickerValue" type="daterange" size="small" class="w-dates" clearable @update:value="pickDates" />
|
||||
<span v-else class="range">{{ shown.rangeText }}</span>
|
||||
<n-spin v-if="loading" :size="14" />
|
||||
<n-text v-if="failed" type="error" class="range">{{ failed }}</n-text>
|
||||
<span v-if="periodValue === 'custom' && !custom" class="range"
|
||||
>选一下开始和结束的日子,没选先按今天算</span
|
||||
>
|
||||
<span v-if="periodValue === 'custom' && !custom" class="range">选一下开始和结束的日子,没选先按今天算</span>
|
||||
<span class="spacer"></span>
|
||||
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
|
||||
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
||||
@@ -421,32 +374,14 @@ function resultWidth(value: number) {
|
||||
/>
|
||||
<template v-if="shown.mode === 'lessons' && lessons">
|
||||
<StatItem label="交过的人" :value="stats.data.length" unit="人" />
|
||||
<StatItem
|
||||
label="上了几节课"
|
||||
:value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`"
|
||||
unit="节"
|
||||
/>
|
||||
<StatItem label="上了几节课" :value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`" unit="节" />
|
||||
</template>
|
||||
<template v-if="personCount">
|
||||
<StatItem
|
||||
:label="hasProblems ? '做完' : '做对过题'"
|
||||
:value="`${doneCount} / ${personCount}`"
|
||||
unit="人"
|
||||
tone="success"
|
||||
/>
|
||||
<StatItem
|
||||
v-if="hasProblems"
|
||||
label="完成度"
|
||||
:value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`"
|
||||
/>
|
||||
<StatItem :label="hasProblems ? '做完' : '做对过题'" :value="`${doneCount} / ${personCount}`" unit="人" tone="success" />
|
||||
<StatItem v-if="hasProblems" label="完成度" :value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`" />
|
||||
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
|
||||
</template>
|
||||
<StatItem
|
||||
:label="hasProblems ? '交了没全对' : '一道没做对'"
|
||||
:value="stats.dataAttempted.length"
|
||||
unit="人"
|
||||
tone="warning"
|
||||
/>
|
||||
<StatItem :label="hasProblems ? '交了没全对' : '一道没做对'" :value="stats.dataAttempted.length" unit="人" tone="warning" />
|
||||
<span class="spacer"></span>
|
||||
<div v-if="results.length" class="result-bar">
|
||||
<div class="result-track">
|
||||
@@ -465,21 +400,11 @@ function resultWidth(value: number) {
|
||||
</div>
|
||||
</div>
|
||||
<span class="ring-wrap">
|
||||
<RingChart
|
||||
:value="stats.acceptedCount"
|
||||
:total="judged"
|
||||
:color="tone('success').solid"
|
||||
:labels="['正确', '没对']"
|
||||
/>
|
||||
<RingChart :value="stats.acceptedCount" :total="judged" :color="tone('success').solid" :labels="['正确', '没对']" />
|
||||
<span class="ring-label">提交<br />正确率</span>
|
||||
</span>
|
||||
<span v-if="personCount && hasProblems" class="ring-wrap">
|
||||
<RingChart
|
||||
:value="doneCount"
|
||||
:total="personCount"
|
||||
:color="tone('info').solid"
|
||||
:labels="['做完', '没做完']"
|
||||
/>
|
||||
<RingChart :value="doneCount" :total="personCount" :color="tone('info').solid" :labels="['做完', '没做完']" />
|
||||
<span class="ring-label">班级<br />完成度</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -493,12 +418,7 @@ function resultWidth(value: number) {
|
||||
@problem="pickProblem"
|
||||
@more="loadMore"
|
||||
/>
|
||||
<ByClass
|
||||
v-else-if="shown.mode === 'byClass' && grid"
|
||||
:grid="grid"
|
||||
:explicit="shown.explicit"
|
||||
@pick="pickClass"
|
||||
/>
|
||||
<ByClass v-else-if="shown.mode === 'byClass' && grid" :grid="grid" :explicit="shown.explicit" @pick="pickClass" />
|
||||
<CodeStats v-else-if="grid" :stats="stats" :grid="grid" :explicit="shown.explicit" />
|
||||
</template>
|
||||
</div>
|
||||
@@ -510,7 +430,7 @@ function resultWidth(value: number) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
/* 弹框:四周留 24,和今日统计同一种圆角卡片 */
|
||||
@@ -519,7 +439,7 @@ function resultWidth(value: number) {
|
||||
max-width: 1480px;
|
||||
height: calc(100vh - 48px);
|
||||
border-radius: 12px;
|
||||
box-shadow: v-bind("theme.boxShadow3");
|
||||
box-shadow: v-bind('theme.boxShadow3');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -532,7 +452,7 @@ function resultWidth(value: number) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -552,12 +472,12 @@ function resultWidth(value: number) {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
color: v-bind("theme.textColor2");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.textColor2');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
@@ -580,7 +500,7 @@ function resultWidth(value: number) {
|
||||
gap: 10px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.result-legend i {
|
||||
@@ -595,7 +515,7 @@ function resultWidth(value: number) {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 4px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
background: v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
/* 最长的是「26计算机11班」:132px 时只剩两像素余量,字体稍宽的机器上就出省略号 */
|
||||
@@ -623,7 +543,7 @@ function resultWidth(value: number) {
|
||||
|
||||
.range {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -637,7 +557,7 @@ function resultWidth(value: number) {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -645,7 +565,7 @@ function resultWidth(value: number) {
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -656,8 +576,8 @@ function resultWidth(value: number) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.ring-wrap {
|
||||
@@ -669,7 +589,7 @@ function resultWidth(value: number) {
|
||||
.ring-label {
|
||||
font-size: var(--oj-fs-meta);
|
||||
line-height: 1.5;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 手机:老师很少在手机上看统计,只保证不坏 —— 筛选和数字行折行,整页自然滚动 */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import StatisticsView from "./components/StatisticsView.vue"
|
||||
import { DEFAULT_PERIOD } from "./period"
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import StatisticsView from './components/StatisticsView.vue'
|
||||
import { DEFAULT_PERIOD } from './period'
|
||||
|
||||
/**
|
||||
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。
|
||||
@@ -11,16 +11,16 @@ import { DEFAULT_PERIOD } from "./period"
|
||||
*/
|
||||
const router = useRouter()
|
||||
|
||||
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
|
||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||
const username = useRouteQuery<string>("username", "", { mode: "replace" })
|
||||
const problem = useRouteQuery<string>("problem", "", { mode: "replace" })
|
||||
const period = useRouteQuery<string>("period", DEFAULT_PERIOD, { mode: "replace" })
|
||||
const from = useRouteQuery<string>("from", "", { mode: "replace" })
|
||||
const to = useRouteQuery<string>("to", "", { mode: "replace" })
|
||||
const tab = useRouteQuery<string>('tab', 'code', { mode: 'replace' })
|
||||
const className = useRouteQuery<string>('className', '', { mode: 'replace' })
|
||||
const username = useRouteQuery<string>('username', '', { mode: 'replace' })
|
||||
const problem = useRouteQuery<string>('problem', '', { mode: 'replace' })
|
||||
const period = useRouteQuery<string>('period', DEFAULT_PERIOD, { mode: 'replace' })
|
||||
const from = useRouteQuery<string>('from', '', { mode: 'replace' })
|
||||
const to = useRouteQuery<string>('to', '', { mode: 'replace' })
|
||||
|
||||
function openList(query: Record<string, string>) {
|
||||
window.open(router.resolve({ name: "submissions", query }).href, "_blank")
|
||||
window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
|
||||
import { parseTime, zonedParts } from "utils/functions"
|
||||
import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
|
||||
import { parseTime, zonedParts } from 'utils/functions'
|
||||
|
||||
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
|
||||
const HOUR = 3_600_000
|
||||
@@ -12,22 +12,22 @@ const DAY = 86_400_000
|
||||
* 那件事现在归课堂看板。
|
||||
*/
|
||||
export const PERIOD_OPTIONS = [
|
||||
{ label: "一节课", value: "1h" },
|
||||
{ label: "两节课", value: "2h" },
|
||||
{ label: "今天", value: "today" },
|
||||
{ label: "最近 7 天", value: "7d" },
|
||||
{ label: "最近 30 天", value: "30d" },
|
||||
{ label: "这学期", value: "term" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "自己选日子", value: "custom" },
|
||||
{ label: '一节课', value: '1h' },
|
||||
{ label: '两节课', value: '2h' },
|
||||
{ label: '今天', value: 'today' },
|
||||
{ label: '最近 7 天', value: '7d' },
|
||||
{ label: '最近 30 天', value: '30d' },
|
||||
{ label: '这学期', value: 'term' },
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '自己选日子', value: 'custom' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* `lesson`:从「一行一节课」点进某一节课,from / to 是那节课第一条、最后一条提交的时刻
|
||||
* (精确到毫秒,不是「自己选日子」那种按天)。它不在下拉里 —— 选中时下拉临时多出一项「那节课」
|
||||
*/
|
||||
export type Period = (typeof PERIOD_OPTIONS)[number]["value"] | "lesson"
|
||||
export const DEFAULT_PERIOD: Period = "1h"
|
||||
export type Period = (typeof PERIOD_OPTIONS)[number]['value'] | 'lesson'
|
||||
export const DEFAULT_PERIOD: Period = '1h'
|
||||
|
||||
/** 东八区某天 0 点对应的 UTC 时刻 */
|
||||
function dayStartOf(year: number, month: number, day: number) {
|
||||
@@ -36,7 +36,7 @@ function dayStartOf(year: number, month: number, day: number) {
|
||||
|
||||
/** 东八区某天(`2026-09-29`)0 点的真实时刻,毫秒 —— 点零散提交时拿它当「自己选日子」的起止 */
|
||||
export function dayStartOfDate(day: string) {
|
||||
const [year, month, date] = day.split("-").map(Number) as [number, number, number]
|
||||
const [year, month, date] = day.split('-').map(Number) as [number, number, number]
|
||||
return dayStartOf(year, month, date)
|
||||
}
|
||||
|
||||
@@ -66,25 +66,25 @@ export function periodRange(period: Period, custom: [number, number] | null, now
|
||||
let start: number | null
|
||||
let end = now
|
||||
switch (period) {
|
||||
case "1h":
|
||||
case '1h':
|
||||
start = now - HOUR
|
||||
break
|
||||
case "2h":
|
||||
case '2h':
|
||||
start = now - 2 * HOUR
|
||||
break
|
||||
case "today":
|
||||
case 'today':
|
||||
start = todayStart(now)
|
||||
break
|
||||
case "7d":
|
||||
case '7d':
|
||||
start = todayStart(now) - 6 * DAY
|
||||
break
|
||||
case "30d":
|
||||
case '30d':
|
||||
start = todayStart(now) - 29 * DAY
|
||||
break
|
||||
case "term":
|
||||
case 'term':
|
||||
start = termStart(now)
|
||||
break
|
||||
case "lesson":
|
||||
case 'lesson':
|
||||
if (custom) {
|
||||
start = custom[0]
|
||||
end = custom[1]
|
||||
@@ -92,7 +92,7 @@ export function periodRange(period: Period, custom: [number, number] | null, now
|
||||
start = now - HOUR
|
||||
}
|
||||
break
|
||||
case "custom":
|
||||
case 'custom':
|
||||
if (custom) {
|
||||
start = custom[0]
|
||||
end = custom[1] + DAY
|
||||
@@ -114,14 +114,14 @@ export function periodRange(period: Period, custom: [number, number] | null, now
|
||||
}
|
||||
|
||||
function describe(start: number | null, end: number, period: Period) {
|
||||
if (start === null) return ""
|
||||
if (period === "1h" || period === "2h") {
|
||||
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
|
||||
if (start === null) return ''
|
||||
if (period === '1h' || period === '2h') {
|
||||
return `${parseTime(new Date(start), 'HH:mm')}–${parseTime(new Date(end), 'HH:mm')}`
|
||||
}
|
||||
if (period === "lesson") {
|
||||
return `${parseTime(new Date(start), "M月D日 HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
|
||||
if (period === 'lesson') {
|
||||
return `${parseTime(new Date(start), 'M月D日 HH:mm')}–${parseTime(new Date(end), 'HH:mm')}`
|
||||
}
|
||||
const from = parseTime(new Date(start), "M月D日")
|
||||
const to = parseTime(new Date(end - 1), "M月D日")
|
||||
const from = parseTime(new Date(start), 'M月D日')
|
||||
const to = parseTime(new Date(end - 1), 'M月D日')
|
||||
return from === to ? from : `${from}–${to}`
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
import type { SUBMISSION_RESULT } from "utils/types"
|
||||
import { useTone, type Tone } from "shared/composables/tone"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { JUDGE_STATUS } from 'utils/constants'
|
||||
import type { SUBMISSION_RESULT } from 'utils/types'
|
||||
import { useTone, type Tone } from 'shared/composables/tone'
|
||||
|
||||
export function resultName(result: number) {
|
||||
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
||||
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? '其他'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,8 +18,8 @@ export function useResultColor() {
|
||||
if (result === -2) return theme.value.textColor3
|
||||
// 等待评分 / 判题中:还没有结果,不该和运行时错误一样是橙色
|
||||
if (result === 6 || result === 7) return theme.value.borderColor
|
||||
const kind: Tone = JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
|
||||
if (kind !== "success" && kind !== "error" && kind !== "warning") return theme.value.borderColor
|
||||
const kind: Tone = JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? 'default'
|
||||
if (kind !== 'success' && kind !== 'error' && kind !== 'warning') return theme.value.borderColor
|
||||
return tone(kind).color
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user