老师的提交列表上只剩一个「统计」按钮;什么都没筛时按课列(选「今天」就是原来今日统计看的), 点一行进那节课(班、题、时段都带好),左上「← 全部」回来;只填题号时仍按班级汇总,和题目页 统计页签对得上。数字行加结果条,统计里的「课堂看板」按钮去掉,改成有班正在上课时才引过去。 学生仍是「今日统计」。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
921990285c
commit
e3be3cd62c
11 files changed
+891
-550
No files matched your search
@@ -40,6 +40,7 @@ import {
|
||||
type UserBadge,
|
||||
type FlowchartStatistics,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionLessons,
|
||||
type SubmissionStatisticsGrid,
|
||||
type TodaySubmissionStatistics,
|
||||
} from "@oj2/contract"
|
||||
@@ -189,6 +190,13 @@ export function getSubmissionStatistics(
|
||||
})
|
||||
}
|
||||
|
||||
/** 统计「一行一节课」的总览(没选班、没填学生和题号时)。limit 是列几节课,「再往前看」加它 */
|
||||
export function getSubmissionLessons(duration: { start?: string; end: string }, limit?: number) {
|
||||
return api.get<SubmissionLessons>("submissions/statistics/lessons", {
|
||||
params: { ...duration, limit },
|
||||
})
|
||||
}
|
||||
|
||||
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
|
||||
export function getSubmissionStatisticsGrid(
|
||||
duration: { start?: string; end: string },
|
||||
|
||||
@@ -269,7 +269,7 @@ function submissionsHref(
|
||||
>
|
||||
</n-text>
|
||||
<n-button :loading="loading" @click="load">刷新</n-button>
|
||||
<!-- 看板只管今天;以前几节课的、任意时间段的去数据统计(带上这个班) -->
|
||||
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||
<n-button
|
||||
v-if="!projector"
|
||||
@click="router.push({ name: 'statistics', query: className ? { className } : {} })"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
/**
|
||||
* 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次
|
||||
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
|
||||
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「数据统计」的活)。
|
||||
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「统计」的活)。
|
||||
* 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
||||
*/
|
||||
const emit = defineEmits<{ openSubmissions: [] }>()
|
||||
@@ -139,7 +139,7 @@ const hiddenClasses = computed(
|
||||
() => (stats.value?.classes?.length ?? 0) - shownClasses.value.length,
|
||||
)
|
||||
|
||||
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
|
||||
/** 点一个班、或者表下面那行:去「统计」按人看(新标签,题目页不动) */
|
||||
function openStatistics(className?: string) {
|
||||
const href = router.resolve({
|
||||
name: "statistics",
|
||||
@@ -273,7 +273,7 @@ function segmentWidth(value: number, total: number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 老师:各班一行,和数据统计「按班级汇总」同一份数;按人看去那边 -->
|
||||
<!-- 老师:各班一行,和统计「按班级汇总」同一份数;按人看去那边 -->
|
||||
<div v-if="stats.classes?.length" class="section">
|
||||
<b>各班做得怎样</b>
|
||||
<table class="classes">
|
||||
@@ -292,7 +292,7 @@ function segmentWidth(value: number, total: number) {
|
||||
v-for="item in shownClasses"
|
||||
:key="item.className ?? ''"
|
||||
:class="{ pickable: item.className }"
|
||||
:title="item.className ? '去数据统计看这个班的每个人' : undefined"
|
||||
:title="item.className ? '新标签看这个班的每个人' : undefined"
|
||||
@click="item.className && openStatistics(item.className)"
|
||||
>
|
||||
<td>
|
||||
@@ -332,7 +332,7 @@ function segmentWidth(value: number, total: number) {
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a href="#" class="link small" @click.prevent="openStatistics()">
|
||||
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||
按人看谁没做对 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
<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 "oj/submission/composables/tone"
|
||||
import { classLabel } from "oj/submission/utils"
|
||||
import { resultName, useResultColor } from "../results"
|
||||
|
||||
type Lesson = SubmissionLessons["lessons"][number]
|
||||
|
||||
/**
|
||||
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
|
||||
* 原来「今日统计」老师那一版(今天哪几个班上了课、几点到几点、错得最多的题)并进来,
|
||||
* 换成任意时间段。按天分组,从近到远 —— 这节课在最上面;点一行进那节课(班、题、时段都带上)。
|
||||
* 课怎么切的在后端(契约 submissionLessonsSchema)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
data: SubmissionLessons
|
||||
/** 时间段落在今天以内:每行多一根 7–20 点的横条(原来今日统计的时间轴) */
|
||||
timeline: boolean
|
||||
/** 「再往前看」在加载 */
|
||||
loadingMore: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
pick: [lesson: Lesson]
|
||||
/** 点「错得最多的题」里的一道:只看这道(按班级汇总) */
|
||||
problem: [displayId: string]
|
||||
more: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const resultColor = useResultColor()
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
|
||||
function dayText(day: string) {
|
||||
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 date = `${m}月${d}日 ${weekday}`
|
||||
if (day === today) return `今天 · ${date}`
|
||||
if (day === yesterday) return `昨天 · ${date}`
|
||||
return y === Number(today.slice(0, 4)) ? date : `${y}年${date}`
|
||||
}
|
||||
|
||||
/** 按天分组,天和天里的课都是从近到远,零散提交挂在对应那天下面 */
|
||||
const days = computed(() => {
|
||||
const map = new Map<string, { lessons: Lesson[]; scattered: string }>()
|
||||
for (const lesson of props.data.lessons) {
|
||||
const entry = map.get(lesson.day) ?? { lessons: [], scattered: "" }
|
||||
entry.lessons.push(lesson)
|
||||
map.set(lesson.day, entry)
|
||||
}
|
||||
const scatteredByDay = new Map<string, string[]>()
|
||||
for (const item of props.data.scattered) {
|
||||
const name = item.className ? classLabel(item.className) : "没填班级的"
|
||||
const text = `${name} ${item.userCount} 人 ${item.total} 次`
|
||||
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), text])
|
||||
if (!map.has(item.day)) map.set(item.day, { lessons: [], scattered: "" })
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([day, entry]) => ({
|
||||
day,
|
||||
label: dayText(day),
|
||||
// 最近的一节在最上面(用户说的):打开就看到这节课,往下是更早的
|
||||
lessons: [...entry.lessons].sort((a, b) => b.start.localeCompare(a.start)),
|
||||
scattered: (scatteredByDay.get(day) ?? []).join(" · "),
|
||||
}))
|
||||
})
|
||||
|
||||
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")}`
|
||||
}
|
||||
|
||||
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
|
||||
function attendance(lesson: Lesson) {
|
||||
return lesson.classSize >= lesson.userCount
|
||||
? `${lesson.userCount} / ${lesson.classSize} 人`
|
||||
: `${lesson.userCount} 人`
|
||||
}
|
||||
|
||||
function problemText(lesson: Lesson) {
|
||||
return `${lesson.fromProblemSet ? "题单 " : ""}${lesson.problems.length} 道`
|
||||
}
|
||||
|
||||
// ---------- 时间轴(只在今天以内) ----------
|
||||
|
||||
function hourOf(value: string) {
|
||||
const p = zonedParts(value)!
|
||||
return p.hour + p.minute / 60
|
||||
}
|
||||
|
||||
/** 横轴默认 7 点到 20 点(机房上课的时段),有更早更晚的课就往外扩 */
|
||||
const axis = computed(() => {
|
||||
const list = props.data.lessons
|
||||
const start = Math.min(7, ...list.map((item) => Math.floor(hourOf(item.start))))
|
||||
const end = Math.max(20, ...list.map((item) => Math.ceil(hourOf(item.end))))
|
||||
const ticks = []
|
||||
for (let hour = start + (start % 2); hour <= end; hour += 2) ticks.push(hour)
|
||||
return { start, end, ticks }
|
||||
})
|
||||
|
||||
function axisLeft(hour: number) {
|
||||
const { start, end } = axis.value
|
||||
return `${((hour - start) / (end - start)) * 100}%`
|
||||
}
|
||||
|
||||
function barStyle(lesson: Lesson) {
|
||||
const from = hourOf(lesson.start)
|
||||
const to = hourOf(lesson.end)
|
||||
const { start, end } = axis.value
|
||||
return {
|
||||
left: axisLeft(from),
|
||||
width: `max(6px, ${((to - from) / (end - start)) * 100}%)`,
|
||||
background: lesson.live ? theme.value.successColor : "#3f5f87",
|
||||
}
|
||||
}
|
||||
|
||||
const nowHour = computed(() => {
|
||||
const hour = hourOf(new Date().toISOString())
|
||||
return hour >= axis.value.start && hour <= axis.value.end ? hour : null
|
||||
})
|
||||
|
||||
function percent(value: number, total: number) {
|
||||
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>
|
||||
<span class="spacer"></span>
|
||||
<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>
|
||||
|
||||
<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)"
|
||||
>
|
||||
<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 class="bar" :style="barStyle(lesson)"></span>
|
||||
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
|
||||
</span>
|
||||
<b class="c-class">{{ classLabel(lesson.className) }}</b>
|
||||
<span class="c-came">{{ attendance(lesson) }}</span>
|
||||
<span class="c-probs">{{ problemText(lesson) }}</span>
|
||||
<span class="c-rate">
|
||||
{{ lesson.total }} 次 ·
|
||||
{{ 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
|
||||
>
|
||||
</template>
|
||||
</span>
|
||||
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
|
||||
</a>
|
||||
<div v-if="group.scattered" class="scattered">
|
||||
{{ group.lessons.length ? "另有零散提交:" : "零散提交:" }}{{ group.scattered }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="timeline && lessonCount" class="axis-row">
|
||||
<span class="c-time"></span>
|
||||
<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) }"
|
||||
>
|
||||
{{ tick }}
|
||||
</span>
|
||||
</template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<template v-if="data.hardProblems.length">
|
||||
<div class="section-title">
|
||||
<b>错得最多的题</b>
|
||||
<n-text depth="3" class="small">按没过的次数 · 点题目看各班做得怎样</n-text>
|
||||
</div>
|
||||
<table class="hard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>题目</th>
|
||||
<th>哪个班</th>
|
||||
<th>提交</th>
|
||||
<th>没过的原因</th>
|
||||
<th class="right">做对</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in data.hardProblems" :key="item.problemDisplayId">
|
||||
<td class="hard-title">
|
||||
<a href="#" @click.prevent="emit('problem', item.problemDisplayId)">
|
||||
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
|
||||
</td>
|
||||
<td>{{ item.total }} 次</td>
|
||||
<td>
|
||||
<span class="mini">
|
||||
<span
|
||||
:style="{ width: percent(item.accepted, item.total), background: resultColor(0) }"
|
||||
></span>
|
||||
<span
|
||||
v-for="fail in item.failures"
|
||||
:key="fail.result"
|
||||
:style="{
|
||||
width: percent(fail.count, item.total),
|
||||
background: resultColor(fail.result),
|
||||
}"
|
||||
></span>
|
||||
</span>
|
||||
<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"
|
||||
:style="{
|
||||
color: item.acceptedUsers < item.userCount ? tone('error').color : undefined,
|
||||
fontWeight: item.acceptedUsers < item.userCount ? 600 : undefined,
|
||||
}"
|
||||
>
|
||||
{{ item.acceptedUsers }} / {{ item.userCount }} 人
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lessons {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 12px 20px 4px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.live-link {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 16px 20px 4px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.day {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
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");
|
||||
}
|
||||
|
||||
.c-time {
|
||||
width: 110px;
|
||||
flex: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.c-axis {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.c-class {
|
||||
width: 120px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.c-came,
|
||||
.c-probs {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.c-rate {
|
||||
width: 170px;
|
||||
flex: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.c-hard {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pid {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.c-go {
|
||||
flex: none;
|
||||
margin-left: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 12px;
|
||||
margin-top: -6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.now {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.axis-row {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.axis-row .c-axis {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.axis span {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.axis .now-label {
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.successColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.scattered {
|
||||
padding: 7px 20px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.more {
|
||||
padding: 12px 20px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 18px 20px 6px;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hard {
|
||||
width: calc(100% - 40px);
|
||||
margin: 0 20px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hard th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: v-bind("theme.textColor3");
|
||||
padding: 4px 8px 6px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.hard td {
|
||||
padding: 8px 8px 8px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hard .right {
|
||||
text-align: right;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.hard-title a {
|
||||
color: v-bind("theme.textColor1");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hard-title a:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.mini {
|
||||
display: inline-flex;
|
||||
width: 70px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 1366 以下(机房 1280):横条窄一点,给「错得最多」留地方 */
|
||||
@media (max-width: 1365px) {
|
||||
.c-axis {
|
||||
width: 150px;
|
||||
margin-right: 14px;
|
||||
}
|
||||
.c-rate {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机:老师很少在手机上看统计,只保证不坏 —— 一行折成两行 */
|
||||
@media (max-width: 767px) {
|
||||
.row {
|
||||
height: auto;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
padding: 8px 14px;
|
||||
white-space: normal;
|
||||
}
|
||||
.c-axis,
|
||||
.axis-row {
|
||||
display: none;
|
||||
}
|
||||
.c-hard {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.hard {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,13 @@ import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type {
|
||||
FlowchartStatistics,
|
||||
SubmissionLessons,
|
||||
SubmissionStatistics,
|
||||
SubmissionStatisticsGrid,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
getFlowchartStatistics,
|
||||
getSubmissionLessons,
|
||||
getSubmissionStatistics,
|
||||
getSubmissionStatisticsGrid,
|
||||
} from "oj/api"
|
||||
@@ -16,15 +18,24 @@ import { fromPickerValue, toPickerValue } from "utils/functions"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
import { classLabel } from "oj/submission/utils"
|
||||
import { DEFAULT_PERIOD, PERIOD_OPTIONS, 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」)。两处用它:提交列表里的弹框(老师
|
||||
* 平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页、今日统计之类从别处
|
||||
* 进来的,条件放在地址栏里能刷新、能转发)。
|
||||
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
|
||||
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的,
|
||||
* 条件放在地址栏里能刷新、能转发)。
|
||||
*
|
||||
* 代码页签按条件分三种看法:
|
||||
* - 什么都没填:**一行一节课**(Lessons)。原来「今日统计」老师那一版并进来了,选「今天」
|
||||
* 就是它,选别的时间段也一样有
|
||||
* - 只填了题号:**按班级汇总**(ByClass)—— 题目页统计页签点进来就是这种,口径和那边的各班表一致
|
||||
* - 选了班或填了学生:左边题目、右边学生(CodeStats)
|
||||
* 从前两种点进去时左上角有「← 全部」回来。
|
||||
*
|
||||
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
|
||||
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。
|
||||
@@ -44,10 +55,10 @@ const emit = defineEmits<{
|
||||
list: [query: Record<string, string>]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const resultColor = useResultColor()
|
||||
|
||||
const tab = defineModel<string>("tab", { default: "code" })
|
||||
const className = defineModel<string>("className", { default: "" })
|
||||
@@ -57,12 +68,6 @@ const period = defineModel<string>("period", { default: DEFAULT_PERIOD })
|
||||
const from = defineModel<string>("from", { default: "" })
|
||||
const to = defineModel<string>("to", { default: "" })
|
||||
|
||||
const periodValue = computed<Period>(() =>
|
||||
PERIOD_OPTIONS.some((option) => option.value === period.value)
|
||||
? (period.value as Period)
|
||||
: DEFAULT_PERIOD,
|
||||
)
|
||||
|
||||
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
|
||||
const custom = computed<[number, number] | null>(() => {
|
||||
const a = Number(from.value)
|
||||
@@ -70,6 +75,20 @@ const custom = computed<[number, number] | null>(() => {
|
||||
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : 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
|
||||
})
|
||||
|
||||
/** 从「一行一节课」点进某一节课时,下拉里临时多一项「那节课」 */
|
||||
const periodOptions = computed(() => [
|
||||
...(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,
|
||||
@@ -99,14 +118,31 @@ const loading = ref(false)
|
||||
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"
|
||||
/** 三种看法,见顶上的注释 */
|
||||
const mode = computed<Mode>(() =>
|
||||
className.value || studentParam.value ? "people" : problem.value.trim() ? "byClass" : "lessons",
|
||||
)
|
||||
|
||||
/** 一次列几节课,「更早的课」每点一次多 50 节 */
|
||||
const LESSON_PAGE = 50
|
||||
const lessonLimit = ref(LESSON_PAGE)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
/**
|
||||
* 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是
|
||||
* 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是
|
||||
* 上一次的,标签先变了就对不上
|
||||
*/
|
||||
const shown = ref({ byClass: true, explicit: false, rangeText: "", withinToday: false })
|
||||
const shown = ref<{ mode: Mode; explicit: boolean; rangeText: string; withinToday: boolean }>({
|
||||
mode: "lessons",
|
||||
explicit: false,
|
||||
rangeText: "",
|
||||
withinToday: false,
|
||||
})
|
||||
let seq = 0
|
||||
|
||||
const ERROR_TEXT: Record<string, string> = {
|
||||
@@ -119,7 +155,7 @@ async function load() {
|
||||
const range = periodRange(periodValue.value, custom.value)
|
||||
const duration = { start: range.start, end: range.end }
|
||||
const snapshot = {
|
||||
byClass: !studentParam.value && !className.value,
|
||||
mode: mode.value,
|
||||
explicit: !!problem.value.trim(),
|
||||
rangeText: range.text,
|
||||
withinToday: range.withinToday,
|
||||
@@ -136,6 +172,16 @@ async function load() {
|
||||
)
|
||||
if (current !== seq) return
|
||||
flow.value = res
|
||||
} else if (snapshot.mode === "lessons") {
|
||||
// 一行一节课不要方块串:那个接口最多 5000 条,课在后端切
|
||||
lessonLimit.value = LESSON_PAGE
|
||||
const [s, l] = await Promise.all([
|
||||
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
||||
getSubmissionLessons(duration, lessonLimit.value),
|
||||
])
|
||||
if (current !== seq) return
|
||||
stats.value = s
|
||||
lessons.value = l
|
||||
} else {
|
||||
const [s, g] = await Promise.all([
|
||||
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
||||
@@ -151,6 +197,7 @@ async function load() {
|
||||
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
|
||||
stats.value = null
|
||||
grid.value = null
|
||||
lessons.value = null
|
||||
flow.value = null
|
||||
const code = (error as { error?: string })?.error ?? ""
|
||||
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
|
||||
@@ -159,6 +206,26 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「更早的课」:只重拉课那一份,数字行不动 */
|
||||
async function loadMore() {
|
||||
const current = seq
|
||||
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,
|
||||
)
|
||||
if (current !== seq) return
|
||||
lessonLimit.value += LESSON_PAGE
|
||||
lessons.value = next
|
||||
} catch {
|
||||
if (current === seq) failed.value = "更早的课没拉下来,再点一次试试"
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([tab, className, periodValue, custom], load)
|
||||
watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
|
||||
@@ -176,11 +243,13 @@ const judged = computed(() =>
|
||||
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
||||
)
|
||||
|
||||
// ---------- 两个入口 ----------
|
||||
// ---------- 右上角的入口 ----------
|
||||
|
||||
/**
|
||||
* 1366 以下(机房 1280 的屏)右上两个入口只留图标。弹框比屏幕窄一圈、还多一个关闭,
|
||||
* 1366 的屏上整排放不下,所以弹框里宽一档才出字
|
||||
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
|
||||
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。
|
||||
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的),
|
||||
* 现在只在有班正在上课时,在课的列表上方引过去
|
||||
*/
|
||||
const narrow = useMediaQuery(props.modal ? "(max-width: 1439px)" : "(max-width: 1365px)")
|
||||
|
||||
@@ -194,16 +263,79 @@ function openList() {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 点进去 / 回来 ----------
|
||||
|
||||
type Snapshot = Record<"className" | "username" | "problem" | "period" | "from" | "to", string>
|
||||
/** 从「一行一节课」或「按班级汇总」点进去之前的条件,「← 全部」拿它回去 */
|
||||
const backTo = ref<Snapshot | null>(null)
|
||||
|
||||
function remember() {
|
||||
backTo.value = {
|
||||
className: className.value,
|
||||
username: username.value,
|
||||
problem: problem.value,
|
||||
period: period.value,
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const snap = backTo.value
|
||||
if (!snap) return
|
||||
backTo.value = null
|
||||
className.value = snap.className
|
||||
username.value = snap.username
|
||||
problem.value = snap.problem
|
||||
period.value = snap.period
|
||||
from.value = snap.from
|
||||
to.value = snap.to
|
||||
}
|
||||
|
||||
// 自己把条件清回了总览,就不用再显示「← 全部」
|
||||
watch(mode, (value) => {
|
||||
if (value === "lessons") backTo.value = null
|
||||
})
|
||||
|
||||
function pickClass(value: string) {
|
||||
remember()
|
||||
className.value = value
|
||||
username.value = ""
|
||||
}
|
||||
|
||||
/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */
|
||||
function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
|
||||
remember()
|
||||
className.value = lesson.className
|
||||
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"
|
||||
}
|
||||
|
||||
/** 点「错得最多的题」里的一道:只看这道,按班级汇总 */
|
||||
function pickProblem(displayId: string) {
|
||||
remember()
|
||||
problem.value = displayId
|
||||
}
|
||||
|
||||
// ---------- 数字行的结果条 ----------
|
||||
|
||||
const results = computed(() => stats.value?.results ?? [])
|
||||
function resultWidth(value: number) {
|
||||
const total = stats.value?.submissionCount ?? 0
|
||||
return total ? `${(value / total) * 100}%` : "0"
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats" :class="{ modal }">
|
||||
<div class="filters">
|
||||
<span class="title">数据统计</span>
|
||||
<span class="title">统计</span>
|
||||
<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>
|
||||
@@ -235,7 +367,7 @@ function pickClass(value: string) {
|
||||
:value="periodValue"
|
||||
class="w-period"
|
||||
size="small"
|
||||
:options="PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value }))"
|
||||
:options="periodOptions"
|
||||
:consistent-menu-width="false"
|
||||
@update:value="(v: string) => (period = v)"
|
||||
/>
|
||||
@@ -255,16 +387,6 @@ function pickClass(value: string) {
|
||||
>选一下开始和结束的日子,没选先按今天算</span
|
||||
>
|
||||
<span class="spacer"></span>
|
||||
<n-button
|
||||
size="small"
|
||||
quaternary
|
||||
title="这节课去课堂看板"
|
||||
aria-label="这节课去课堂看板"
|
||||
@click="router.push('/classroom')"
|
||||
>
|
||||
<template #icon><Icon icon="ph:monitor" /></template>
|
||||
<template v-if="!narrow">这节课去课堂看板</template>
|
||||
</n-button>
|
||||
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
|
||||
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
||||
<template v-if="!narrow">在提交列表里看</template>
|
||||
@@ -278,7 +400,7 @@ function pickClass(value: string) {
|
||||
<FlowStats v-if="flow" :data="flow" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="stats && grid">
|
||||
<template v-else-if="stats && (shown.mode === 'lessons' ? lessons : grid)">
|
||||
<div class="summary">
|
||||
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
||||
<StatItem
|
||||
@@ -286,6 +408,14 @@ function pickClass(value: string) {
|
||||
:value="stats.submissionCount ? `${Math.round(stats.correctRate)}%` : '—'"
|
||||
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
||||
/>
|
||||
<template v-if="shown.mode === 'lessons' && lessons">
|
||||
<StatItem label="交过的人" :value="stats.data.length" unit="人" />
|
||||
<StatItem
|
||||
label="上了几节课"
|
||||
:value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`"
|
||||
unit="节"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="personCount">
|
||||
<StatItem
|
||||
:label="hasProblems ? '做完' : '做对过题'"
|
||||
@@ -307,6 +437,22 @@ function pickClass(value: string) {
|
||||
tone="warning"
|
||||
/>
|
||||
<span class="spacer"></span>
|
||||
<div v-if="results.length" class="result-bar">
|
||||
<div class="result-track">
|
||||
<span
|
||||
v-for="item in results"
|
||||
:key="item.result"
|
||||
:style="{ width: resultWidth(item.count), background: resultColor(item.result) }"
|
||||
:title="`${resultName(item.result)} ${item.count}`"
|
||||
></span>
|
||||
</div>
|
||||
<div class="result-legend">
|
||||
<span v-for="item in results.slice(0, 3)" :key="item.result">
|
||||
<i :style="{ background: resultColor(item.result) }"></i>{{ resultName(item.result) }}
|
||||
<b>{{ item.count }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ring-wrap">
|
||||
<RingChart
|
||||
:value="stats.acceptedCount"
|
||||
@@ -326,8 +472,22 @@ function pickClass(value: string) {
|
||||
<span class="ring-label">班级<br />完成度</span>
|
||||
</span>
|
||||
</div>
|
||||
<ByClass v-if="shown.byClass" :grid="grid" :explicit="shown.explicit" @pick="pickClass" />
|
||||
<CodeStats v-else :stats="stats" :grid="grid" :explicit="shown.explicit" />
|
||||
<Lessons
|
||||
v-if="shown.mode === 'lessons' && lessons"
|
||||
:data="lessons"
|
||||
:timeline="shown.withinToday"
|
||||
:loading-more="loadingMore"
|
||||
@pick="pickLesson"
|
||||
@problem="pickProblem"
|
||||
@more="loadMore"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
@@ -369,6 +529,56 @@ function pickClass(value: string) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.back {
|
||||
height: 28px;
|
||||
padding: 0 10px 0 6px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
color: v-bind("theme.textColor2");
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.result-track {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.result-legend {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.result-legend i {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.vsep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
|
||||
@@ -4,7 +4,7 @@ import StatisticsView from "./components/StatisticsView.vue"
|
||||
import { DEFAULT_PERIOD } from "./period"
|
||||
|
||||
/**
|
||||
* 数据统计的页面形态:课堂看板「回头看」、题目页统计页签、今日统计从别处进来时用。
|
||||
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。
|
||||
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
|
||||
*
|
||||
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
|
||||
|
||||
@@ -22,7 +22,11 @@ export const PERIOD_OPTIONS = [
|
||||
{ label: "自己选日子", value: "custom" },
|
||||
] as const
|
||||
|
||||
export type Period = (typeof PERIOD_OPTIONS)[number]["value"]
|
||||
/**
|
||||
* `lesson`:从「一行一节课」点进某一节课,from / to 是那节课第一条、最后一条提交的时刻
|
||||
* (精确到毫秒,不是「自己选日子」那种按天)。它不在下拉里 —— 选中时下拉临时多出一项「那节课」
|
||||
*/
|
||||
export type Period = (typeof PERIOD_OPTIONS)[number]["value"] | "lesson"
|
||||
export const DEFAULT_PERIOD: Period = "1h"
|
||||
|
||||
/** 东八区某天 0 点对应的 UTC 时刻 */
|
||||
@@ -74,6 +78,14 @@ export function periodRange(period: Period, custom: [number, number] | null, now
|
||||
case "term":
|
||||
start = termStart(now)
|
||||
break
|
||||
case "lesson":
|
||||
if (custom) {
|
||||
start = custom[0]
|
||||
end = custom[1]
|
||||
} else {
|
||||
start = now - HOUR
|
||||
}
|
||||
break
|
||||
case "custom":
|
||||
if (custom) {
|
||||
start = custom[0]
|
||||
@@ -100,6 +112,9 @@ function describe(start: number | null, end: number, period: Period) {
|
||||
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")}`
|
||||
}
|
||||
const from = parseTime(new Date(start), "M月D日")
|
||||
const to = parseTime(new Date(end - 1), "M月D日")
|
||||
return from === to ? from : `${from}–${to}`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
import type { SUBMISSION_RESULT } from "utils/types"
|
||||
import { useTone, type Tone } from "oj/submission/composables/tone"
|
||||
|
||||
export function resultName(result: number) {
|
||||
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果条的颜色:答案正确绿、运行时错误橙、答案错误红、编译失败和判题中灰。
|
||||
* 数字行那根结果条和「错得最多的题」的小条共用(原来在今日统计里)
|
||||
*/
|
||||
export function useResultColor() {
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
return function resultColor(result: number) {
|
||||
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
|
||||
return tone(kind).color
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,22 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getTodaySubmissionStatistics } from "oj/api"
|
||||
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime, zonedParts } from "utils/functions"
|
||||
import type { SUBMISSION_RESULT, TodaySubmissionStatistics } from "utils/types"
|
||||
import { useTone, type Tone } from "../composables/tone"
|
||||
import { classLabel } from "../utils"
|
||||
import type { TodaySubmissionStatistics } from "utils/types"
|
||||
|
||||
/**
|
||||
* 今日统计(设计稿「今日统计重设计」)。一般是老师在看:今天哪几个班上了课、几点到几点、
|
||||
* 错得最多的是哪几道(老师 B)。学生版从简:自己今天怎样、大家在做哪几道。
|
||||
* 盯人、实时刷新是课堂看板的活,这里不做;有班正在上课时直接引过去。
|
||||
* 今日统计(设计稿「今日统计重设计」学生版),**只给学生**:自己今天怎样、大家在做哪几道。
|
||||
* 老师那一版(今天哪几个班上了课、几点到几点、错得最多的题)2026-09 并进了「统计」的
|
||||
* 「一行一节课」(oj/statistics/components/Lessons.vue),老师的提交列表上不再有这颗标签。
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
openProblem: [problem: string]
|
||||
/** 点班级名:在提交列表的数据统计弹框里看这个班今天 */
|
||||
openClass: [className: string]
|
||||
/** 拉到数之后把总数报回去:按钮上的数只在进页面时拉过一次,停久了会对不上 */
|
||||
loaded: [total: number]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
const stats = ref<TodaySubmissionStatistics | null>(null)
|
||||
const failed = ref(false)
|
||||
@@ -38,8 +31,6 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const teacher = computed(() => stats.value?.classes != null)
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
|
||||
const headText = computed(() => {
|
||||
@@ -49,101 +40,6 @@ const headText = computed(() => {
|
||||
return `${p.month}月${p.day}日 ${weekday} · 截至 ${parseTime(at, "HH:mm")}`
|
||||
})
|
||||
|
||||
function resultName(result: number) {
|
||||
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
||||
}
|
||||
|
||||
/** 结果条的颜色:答案正确绿、运行时错误橙、答案错误红、编译失败和判题中灰 */
|
||||
function resultColor(result: number) {
|
||||
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
|
||||
return tone(kind).color
|
||||
}
|
||||
|
||||
function percent(value: number, total: number) {
|
||||
return total ? `${(value / total) * 100}%` : "0"
|
||||
}
|
||||
|
||||
// ---------- 老师:各班时间轴 ----------
|
||||
|
||||
/** 东八区的「几点几分」换成小时的小数,给时间轴定位 */
|
||||
function hourOf(value: string) {
|
||||
const p = zonedParts(value)!
|
||||
return p.hour + p.minute / 60
|
||||
}
|
||||
|
||||
/** 横轴默认 7 点到 20 点(机房上课的时段),有更早更晚的提交就往外扩 */
|
||||
const axis = computed(() => {
|
||||
const list = stats.value?.classes ?? []
|
||||
const start = Math.min(7, ...list.map((item) => Math.floor(hourOf(item.start))))
|
||||
const end = Math.max(20, ...list.map((item) => Math.ceil(hourOf(item.end))))
|
||||
return { start, end, ticks: Array.from({ length: end - start + 1 }, (_, i) => start + i) }
|
||||
})
|
||||
|
||||
function axisLeft(hour: number) {
|
||||
const { start, end } = axis.value
|
||||
return `${((hour - start) / (end - start)) * 100}%`
|
||||
}
|
||||
|
||||
function barStyle(item: { start: string; end: string; live: boolean }) {
|
||||
const from = hourOf(item.start)
|
||||
const to = hourOf(item.end)
|
||||
const { start, end } = axis.value
|
||||
return {
|
||||
left: axisLeft(from),
|
||||
width: `max(8px, ${((to - from) / (end - start)) * 100}%)`,
|
||||
background: item.live ? theme.value.successColor : "#3f5f87",
|
||||
}
|
||||
}
|
||||
|
||||
/** 横条靠近右边时文字放到条的左边,不然「497 次 · 正确率 31%」被弹框右边裁掉 */
|
||||
function textStyle(item: { start: string; end: string }) {
|
||||
const { start, end } = axis.value
|
||||
if ((hourOf(item.end) - start) / (end - start) > 0.72) {
|
||||
return { right: `calc(100% - ${axisLeft(hourOf(item.start))} + 10px)` }
|
||||
}
|
||||
return { left: `calc(${axisLeft(hourOf(item.end))} + 14px)` }
|
||||
}
|
||||
|
||||
const nowHour = computed(() => {
|
||||
const at = stats.value?.asOf
|
||||
if (!at) return null
|
||||
const hour = hourOf(at)
|
||||
return hour >= axis.value.start && hour <= axis.value.end ? hour : null
|
||||
})
|
||||
|
||||
const liveClasses = computed(() => (stats.value?.classes ?? []).filter((item) => item.live))
|
||||
|
||||
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
|
||||
function attendance(item: { userCount: number; classSize: number }) {
|
||||
return item.classSize >= item.userCount
|
||||
? `${item.userCount} / ${item.classSize} 人`
|
||||
: `${item.userCount} 人`
|
||||
}
|
||||
|
||||
const scatteredText = computed(() =>
|
||||
(stats.value?.scattered ?? [])
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.className ? classLabel(item.className) : "没填班级的"} ${item.userCount} 人 ${item.total} 次`,
|
||||
)
|
||||
.join("、"),
|
||||
)
|
||||
|
||||
const judgedResults = computed(() => stats.value?.results ?? [])
|
||||
|
||||
const languageText = computed(() => {
|
||||
const list = stats.value?.languages ?? []
|
||||
if (!list.length) return ""
|
||||
if (list.length === 1) return `语言:全部是 ${LANGUAGE_SHOW_VALUE[list[0]!.language]}`
|
||||
return `语言:${list.map((item) => `${LANGUAGE_SHOW_VALUE[item.language]} ${item.count}`).join(" · ")}`
|
||||
})
|
||||
|
||||
// ---------- 学生 ----------
|
||||
|
||||
const meText = computed(() => {
|
||||
const me = stats.value?.me
|
||||
if (!me) return ""
|
||||
@@ -152,7 +48,7 @@ const meText = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="today" :class="{ wide: teacher }">
|
||||
<div class="today">
|
||||
<div class="head">
|
||||
<b class="title">今日统计</b>
|
||||
<n-text depth="3" class="small">{{ headText }}</n-text>
|
||||
@@ -173,186 +69,6 @@ const meText = computed(() => {
|
||||
<n-text depth="3">第一条提交进来之后这里就有数了</n-text>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 老师 ==================== -->
|
||||
<template v-else-if="teacher">
|
||||
<div class="numbers">
|
||||
<div>
|
||||
<b>{{ stats.total }}</b
|
||||
><span>次提交</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ stats.userCount }}</b
|
||||
><span>人</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ stats.correctRate }}%</b><span>提交正确率</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ stats.classes!.length }}</b
|
||||
><span>个班上了课</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="liveClasses.length"
|
||||
class="live"
|
||||
:style="{ background: tone('success').background, color: tone('success').color }"
|
||||
>
|
||||
<span>
|
||||
{{ liveClasses.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>
|
||||
</div>
|
||||
|
||||
<template v-if="stats.classes!.length">
|
||||
<div class="section-title">
|
||||
<b>今天哪几个班上了课</b>
|
||||
<n-text depth="3" class="small">点班级名去数据统计看今天</n-text>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div v-for="item in stats.classes" :key="item.className" class="lane">
|
||||
<div class="lane-label">
|
||||
<a href="#" class="lane-name" @click.prevent="emit('openClass', item.className)">
|
||||
{{ classLabel(item.className) }}
|
||||
</a>
|
||||
<n-text depth="3" class="tiny">
|
||||
{{ attendance(item) }} · {{ item.fromProblemSet ? "题单" : ""
|
||||
}}{{ item.problemCount }} 道题
|
||||
</n-text>
|
||||
</div>
|
||||
<div class="lane-track">
|
||||
<span
|
||||
v-for="tick in axis.ticks"
|
||||
:key="tick"
|
||||
class="grid"
|
||||
:style="{ left: axisLeft(tick) }"
|
||||
></span>
|
||||
<span class="lane-bar" :style="barStyle(item)"></span>
|
||||
<span class="lane-text" :style="textStyle(item)">
|
||||
{{ item.total }} 次 ·
|
||||
{{ item.live ? "上课中" : `正确率 ${Math.round(item.correctRate)}%` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="nowHour !== null"
|
||||
class="now"
|
||||
:style="{ left: axisLeft(nowHour) }"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lane axis-row">
|
||||
<div class="lane-label"></div>
|
||||
<div class="lane-track axis">
|
||||
<span v-for="tick in axis.ticks" :key="tick" :style="{ left: axisLeft(tick) }">
|
||||
{{ tick }}
|
||||
</span>
|
||||
<span
|
||||
v-if="nowHour !== null"
|
||||
class="now-label"
|
||||
:style="{ left: axisLeft(nowHour) }"
|
||||
>
|
||||
现在
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<n-text v-if="scatteredText" depth="3" class="small">
|
||||
{{ stats.classes!.length ? "另有零散提交:" : "今天没有哪个班一起上课,零散提交:"
|
||||
}}{{ scatteredText }}
|
||||
</n-text>
|
||||
|
||||
<template v-if="stats.hardProblems?.length">
|
||||
<div class="section-title">
|
||||
<b>错得最多的题</b>
|
||||
<n-text depth="3" class="small">按没过的次数</n-text>
|
||||
</div>
|
||||
<table class="hard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>题目</th>
|
||||
<th>哪个班</th>
|
||||
<th>提交</th>
|
||||
<th>没过的原因</th>
|
||||
<th class="right">做对</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in stats.hardProblems" :key="item.problemDisplayId">
|
||||
<td class="hard-title">
|
||||
<a href="#" @click.prevent="emit('openProblem', item.problemDisplayId)">
|
||||
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
|
||||
</td>
|
||||
<td>{{ item.total }} 次</td>
|
||||
<td>
|
||||
<span class="mini">
|
||||
<span
|
||||
:style="{
|
||||
width: percent(item.accepted, item.total),
|
||||
background: resultColor(0),
|
||||
}"
|
||||
></span>
|
||||
<span
|
||||
v-for="fail in item.failures"
|
||||
:key="fail.result"
|
||||
:style="{
|
||||
width: percent(fail.count, item.total),
|
||||
background: resultColor(fail.result),
|
||||
}"
|
||||
></span>
|
||||
</span>
|
||||
<n-text depth="2" class="small">
|
||||
{{ resultName(item.failures[0]!.result) }} {{ item.failures[0]!.count }}
|
||||
</n-text>
|
||||
</td>
|
||||
<td
|
||||
class="right"
|
||||
:style="{
|
||||
color: item.acceptedUsers < item.userCount ? tone('error').color : undefined,
|
||||
fontWeight: item.acceptedUsers < item.userCount ? 600 : undefined,
|
||||
}"
|
||||
>
|
||||
{{ item.acceptedUsers }} / {{ item.userCount }} 人
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<div class="foot">
|
||||
<div class="foot-bar">
|
||||
<div class="results">
|
||||
<span
|
||||
v-for="item in judgedResults"
|
||||
:key="item.result"
|
||||
:style="{
|
||||
width: percent(item.count, stats.total),
|
||||
background: resultColor(item.result),
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span v-for="item in judgedResults.slice(0, 5)" :key="item.result">
|
||||
<i :style="{ background: resultColor(item.result) }"></i
|
||||
>{{ resultName(item.result) }}
|
||||
<b>{{ item.count }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-text depth="3" class="small foot-meta">
|
||||
{{ languageText }} ·
|
||||
{{ stats.flowchartCount ? `流程图 ${stats.flowchartCount} 张` : "流程图今天没人画" }}
|
||||
</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== 学生:从简 ==================== -->
|
||||
<template v-else>
|
||||
<div v-if="meText" class="me">
|
||||
<b>你今天</b>
|
||||
@@ -412,10 +128,6 @@ const meText = computed(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.today.wide {
|
||||
width: min(880px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -470,10 +182,6 @@ const meText = computed(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -521,200 +229,12 @@ const meText = computed(() => {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.live-link {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lane {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.lane.axis-row {
|
||||
min-height: 24px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.lane-label {
|
||||
width: 150px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lane-name {
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor1");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lane-name:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.lane-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.lane-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.lane-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.now {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.axis span {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.axis .now-label {
|
||||
top: 4px;
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.successColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.hard {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hard th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: v-bind("theme.textColor3");
|
||||
padding: 4px 8px 6px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.hard td {
|
||||
padding: 8px 8px 8px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hard .right {
|
||||
text-align: right;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.hard-title a {
|
||||
color: v-bind("theme.textColor1");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hard-title a:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.mini {
|
||||
display: inline-flex;
|
||||
width: 70px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.foot-bar {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.legend i {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getTodaySubmissionCount,
|
||||
retryFlowchartSubmission,
|
||||
} from "oj/api"
|
||||
import { DEFAULT_PERIOD, type Period } from "oj/statistics/period"
|
||||
import { DEFAULT_PERIOD } from "oj/statistics/period"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
@@ -38,7 +38,7 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils"
|
||||
*
|
||||
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
|
||||
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
|
||||
* 完成情况在课堂看板和「数据统计」里,这页不重复造一张表。
|
||||
* 完成情况在课堂看板和「统计」里,这页不重复造一张表。
|
||||
*/
|
||||
|
||||
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
|
||||
@@ -190,7 +190,8 @@ async function getTodayCount() {
|
||||
|
||||
onMounted(() => {
|
||||
listSubmissions()
|
||||
if (route.name === "submissions") getTodayCount()
|
||||
// 「今日统计 N」只给学生:老师那一版并进了「统计」
|
||||
if (route.name === "submissions" && !teacher.value) getTodayCount()
|
||||
})
|
||||
|
||||
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||||
@@ -483,9 +484,6 @@ const scope = computed({
|
||||
},
|
||||
})
|
||||
|
||||
/** 1366 以下(机房 1280 的屏)老师那排放不下:「今日统计」只留图标和数字 */
|
||||
const narrowBar = useMediaQuery("(max-width: 1365px)")
|
||||
|
||||
const today = computed({
|
||||
get: () => query.today === "1",
|
||||
set: (value: boolean) => (query.today = value ? "1" : "0"),
|
||||
@@ -564,7 +562,7 @@ function filterUser(username: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 「数据统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
|
||||
* 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
|
||||
* 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的
|
||||
*/
|
||||
const statsQuery = reactive({
|
||||
@@ -577,14 +575,18 @@ const statsQuery = reactive({
|
||||
to: "",
|
||||
})
|
||||
|
||||
function openStatistics(preset?: { className: string; period: Period }) {
|
||||
todayPanel.value = false
|
||||
/**
|
||||
* 老师的「统计」:带着这张列表现在的班级 / 学生 / 题号 / 代码或流程图打开。原来旁边还有一颗
|
||||
* 「今日统计」,和「数据统计」名字分不清(用户说的),老师那一版并进来了 —— 什么都没筛时
|
||||
* 打开就是「一行一节课」,选「今天」就是原来今日统计看的东西
|
||||
*/
|
||||
function openStatistics() {
|
||||
Object.assign(statsQuery, {
|
||||
tab: flowMode.value ? "flow" : "code",
|
||||
className: preset ? preset.className : query.className,
|
||||
username: preset ? "" : query.username,
|
||||
problem: preset ? "" : query.problem,
|
||||
...(preset ? { period: preset.period } : query.today === "1" ? { period: "today" } : {}),
|
||||
className: query.className,
|
||||
username: query.username,
|
||||
problem: query.problem,
|
||||
...(query.today === "1" ? { period: "today" } : {}),
|
||||
})
|
||||
statsPanel.value = true
|
||||
}
|
||||
@@ -814,27 +816,28 @@ function dayBreak(index: number) {
|
||||
<n-button size="small" quaternary @click="clear">清空</n-button>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<!-- 老师只有「统计」(今日统计并进去了);学生看不到统计,仍是「今日统计」 -->
|
||||
<n-button
|
||||
v-if="route.name === 'submissions' && !flowMode"
|
||||
v-if="teacher && route.name === 'submissions'"
|
||||
size="small"
|
||||
quaternary
|
||||
title="统计:这节课、今天,或者回头看以前的课"
|
||||
@click="openStatistics()"
|
||||
>
|
||||
<template #icon><Icon icon="ph:chart-bar" /></template>
|
||||
统计
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else-if="route.name === 'submissions' && !flowMode"
|
||||
size="small"
|
||||
quaternary
|
||||
:title="`今日统计:今天全站 ${todayCount} 条`"
|
||||
@click="toggleTodayPanel(true)"
|
||||
>
|
||||
<template #icon><Icon icon="ph:chart-bar" /></template>
|
||||
<template v-if="!(teacher && narrowBar)">今日统计</template>
|
||||
今日统计
|
||||
<span v-if="todayCount" class="count">{{ todayCount }}</span>
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="teacher && route.name === 'submissions'"
|
||||
size="small"
|
||||
quaternary
|
||||
title="数据统计:按班级、题号、时间段回头看"
|
||||
@click="openStatistics()"
|
||||
>
|
||||
<template #icon><Icon icon="ph:chart-pie-slice" /></template>
|
||||
数据统计
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 手机:只留全部/我的,其余收进「筛选」 -->
|
||||
@@ -1109,7 +1112,6 @@ function dayBreak(index: number) {
|
||||
@close="todayPanel = false"
|
||||
@loaded="(total: number) => (todayCount = total)"
|
||||
@open-problem="(id: string) => openProblem({ problemDisplayId: id } as Row)"
|
||||
@open-class="(name: string) => openStatistics({ className: name, period: 'today' })"
|
||||
/>
|
||||
</n-modal>
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
|
||||
<div class="actions">
|
||||
<ProblemJumpBox v-if="isDesktop" :compact="compact" />
|
||||
<template v-if="isDesktop && collabStore.isTeacher">
|
||||
<!-- 和提交列表的「今日统计」「数据统计」同一种按钮:平时没底色,悬停出底色 -->
|
||||
<!-- 和提交列表的「统计」同一种按钮:平时没底色,悬停出底色 -->
|
||||
<n-button
|
||||
quaternary
|
||||
class="tool-link"
|
||||
|
||||
Reference in new issue
Block a user