Deploy / deploy (push) Canceled after 0s
- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排; 只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=) - 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行; 看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。 不带 lessonId/newLesson 的老请求照旧改今天最后一节 - 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」), 这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间 - 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节; 看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用 ⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
642 lines
22 KiB
Vue
642 lines
22 KiB
Vue
<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'
|
||
|
||
/**
|
||
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
|
||
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的,
|
||
* 条件放在地址栏里能刷新、能转发)。
|
||
*
|
||
* 代码页签按条件分三种看法:
|
||
* - 什么都没填:**一行一节课**(Lessons)。原来「今日统计」老师那一版并进来了,选「今天」
|
||
* 就是它,选别的时间段也一样有
|
||
* - 只填了题号:**按班级汇总**(ByClass)—— 题目页统计页签点进来就是这种,口径和那边的各班表一致
|
||
* - 选了班或填了学生:左边题目、右边学生(CodeStats)
|
||
* 从前两种点进去时左上角有「← 全部」回来。
|
||
*
|
||
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
|
||
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。
|
||
*
|
||
* 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。
|
||
*/
|
||
const FlowStats = defineAsyncComponent(() => import('./FlowStats.vue'))
|
||
|
||
const props = defineProps<{
|
||
/** 在弹框里:右上角多一个关闭;「在提交列表里看」由外面接住,改的是背后那张列表 */
|
||
modal?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
/** 「在提交列表里看」:带着现在的条件回提交列表(query 是提交列表的地址栏参数) */
|
||
list: [query: Record<string, string>]
|
||
}>()
|
||
|
||
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: '' })
|
||
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>(() => {
|
||
const a = Number(from.value)
|
||
const b = Number(to.value)
|
||
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
||
})
|
||
|
||
/**
|
||
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样
|
||
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
|
||
*/
|
||
const doneLabel = computed(() => {
|
||
const value = periodValue.value
|
||
if (value === 'today' || (value === 'custom' && custom.value && custom.value[0] === custom.value[1])) return '这一天做完'
|
||
if (value === 'lesson' || value === '1h' || value === '2h') return '这节课做完'
|
||
return '这段时间做完'
|
||
})
|
||
|
||
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))
|
||
function pickDates(value: [number, number] | null) {
|
||
if (!value) {
|
||
from.value = ''
|
||
to.value = ''
|
||
return
|
||
}
|
||
from.value = String(fromPickerValue(value[0]))
|
||
to.value = String(fromPickerValue(value[1]))
|
||
}
|
||
|
||
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
|
||
const studentParam = computed(() => username.value.trim())
|
||
|
||
const classOptions = computed(() => classSelectOptions(configStore.config?.classList ?? [], className.value))
|
||
|
||
// ---------- 取数 ----------
|
||
|
||
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<{ mode: Mode; explicit: boolean; rangeText: string; withinToday: boolean }>({
|
||
mode: 'lessons',
|
||
explicit: false,
|
||
rangeText: '',
|
||
withinToday: false,
|
||
})
|
||
let seq = 0
|
||
|
||
const ERROR_TEXT: Record<string, string> = {
|
||
'problem-not-found': '有题号找不到,检查一下是不是打错了',
|
||
'invalid-request': '一次最多查 20 道题',
|
||
}
|
||
|
||
async function load() {
|
||
const current = ++seq
|
||
const range = periodRange(periodValue.value, custom.value)
|
||
const duration = { start: range.start, end: range.end }
|
||
const snapshot = {
|
||
mode: mode.value,
|
||
explicit: !!problem.value.trim(),
|
||
rangeText: range.text,
|
||
withinToday: range.withinToday,
|
||
}
|
||
loading.value = true
|
||
failed.value = ''
|
||
try {
|
||
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') {
|
||
// 一行一节课不要方块串:那个接口最多 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),
|
||
getSubmissionStatisticsGrid(duration, problem.value, studentParam.value, className.value),
|
||
])
|
||
if (current !== seq) return
|
||
stats.value = s
|
||
grid.value = g
|
||
}
|
||
shown.value = snapshot
|
||
} catch (error) {
|
||
if (current !== seq) return
|
||
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
|
||
stats.value = null
|
||
grid.value = null
|
||
lessons.value = null
|
||
flow.value = null
|
||
const code = (error as { error?: string })?.error ?? ''
|
||
failed.value = ERROR_TEXT[code] ?? '没拉下来,改一下条件再试'
|
||
} finally {
|
||
if (current === seq) loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 「更早的课」:只重拉课那一份,数字行不动 */
|
||
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 })
|
||
|
||
// ---------- 数字行(代码) ----------
|
||
|
||
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
|
||
/**
|
||
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
|
||
* 「全做完」。这时各处都叫「做对过题」(CodeStats、ByClass 同一个口径)
|
||
*/
|
||
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))
|
||
|
||
// ---------- 右上角的入口 ----------
|
||
|
||
/**
|
||
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
|
||
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。
|
||
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的),
|
||
* 现在只在有班正在上课时,在课的列表上方引过去
|
||
*/
|
||
const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)')
|
||
|
||
function openList() {
|
||
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' } : {}),
|
||
})
|
||
}
|
||
|
||
// ---------- 点进去 / 回来 ----------
|
||
|
||
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 ?? NO_CLASS
|
||
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 pickScattered(item: SubmissionLessons['scattered'][number]) {
|
||
remember()
|
||
className.value = item.className ?? NO_CLASS
|
||
username.value = ''
|
||
problem.value = ''
|
||
const day = String(dayStartOfDate(item.day))
|
||
from.value = day
|
||
to.value = day
|
||
period.value = 'custom'
|
||
}
|
||
|
||
/** 点「错得最多的题」里的一道:只看这道,按班级汇总 */
|
||
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>
|
||
<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>
|
||
</n-radio-group>
|
||
<span class="vsep"></span>
|
||
<n-select
|
||
:value="className || null"
|
||
class="w-class"
|
||
size="small"
|
||
:options="classOptions"
|
||
placeholder="全部班级"
|
||
clearable
|
||
filterable
|
||
@update:value="(v: string | null) => (className = v ?? '')"
|
||
/>
|
||
<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="题号,几道用空格隔开">
|
||
<template #prefix><Icon icon="ph:hash" /></template>
|
||
</n-input>
|
||
<n-select
|
||
:value="periodValue"
|
||
class="w-period"
|
||
size="small"
|
||
:options="periodOptions"
|
||
: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" />
|
||
<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 class="spacer"></span>
|
||
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
|
||
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
||
<template v-if="!narrow">在提交列表里看</template>
|
||
</n-button>
|
||
<button v-if="modal" type="button" class="close" aria-label="关闭" @click="emit('close')">
|
||
<Icon icon="ph:x" :width="18" />
|
||
</button>
|
||
</div>
|
||
|
||
<template v-if="tab === 'flow'">
|
||
<FlowStats v-if="flow" :data="flow" />
|
||
</template>
|
||
|
||
<template v-else-if="stats && (shown.mode === 'lessons' ? lessons : grid)">
|
||
<div class="summary">
|
||
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
||
<StatItem
|
||
label="正确率"
|
||
: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 ? doneLabel : '做对过题'" :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" />
|
||
<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" :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="['做完', '没做完']" />
|
||
<span class="ring-label">班级<br />完成度</span>
|
||
</span>
|
||
</div>
|
||
<Lessons
|
||
v-if="shown.mode === 'lessons' && lessons"
|
||
:data="lessons"
|
||
:timeline="shown.withinToday"
|
||
:loading-more="loadingMore"
|
||
@pick="pickLesson"
|
||
@scattered="pickScattered"
|
||
@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>
|
||
|
||
<style scoped>
|
||
/* 高度由外面给(页面铺满顶栏以下、弹框按屏幕高),里面几栏各自滚动 */
|
||
.stats {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: v-bind('theme.cardColor');
|
||
}
|
||
|
||
/* 弹框:四周留 24,和今日统计同一种圆角卡片 */
|
||
.stats.modal {
|
||
width: calc(100vw - 48px);
|
||
max-width: 1480px;
|
||
height: calc(100vh - 48px);
|
||
border-radius: 12px;
|
||
box-shadow: v-bind('theme.boxShadow3');
|
||
overflow: hidden;
|
||
}
|
||
|
||
.filters {
|
||
height: 60px;
|
||
flex: none;
|
||
box-sizing: border-box;
|
||
padding: 0 var(--oj-pad-x);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||
}
|
||
|
||
.title {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.back {
|
||
height: 32px;
|
||
padding: 0 10px 0 6px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
font: inherit;
|
||
font-size: var(--oj-fs-sec);
|
||
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: var(--oj-fs-meta);
|
||
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;
|
||
margin: 0 4px;
|
||
background: v-bind('theme.dividerColor');
|
||
}
|
||
|
||
/* 最长的是「26计算机11班」:132px 时只剩两像素余量,字体稍宽的机器上就出省略号 */
|
||
.w-class {
|
||
width: 152px;
|
||
}
|
||
.w-user {
|
||
width: 110px;
|
||
}
|
||
.w-problem {
|
||
width: 220px;
|
||
}
|
||
.w-period {
|
||
width: 150px;
|
||
}
|
||
.w-dates {
|
||
width: 240px;
|
||
}
|
||
|
||
@media (max-width: 1365px) {
|
||
.w-problem {
|
||
width: 190px;
|
||
}
|
||
}
|
||
|
||
.range {
|
||
font-size: var(--oj-fs-meta);
|
||
color: v-bind('theme.textColor3');
|
||
}
|
||
|
||
.spacer {
|
||
flex: 1 1 0;
|
||
}
|
||
|
||
.close {
|
||
width: 32px;
|
||
height: 32px;
|
||
margin-right: -8px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: v-bind('theme.textColor2');
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.close:hover {
|
||
background: v-bind('theme.actionColor');
|
||
}
|
||
|
||
.summary {
|
||
height: 84px;
|
||
flex: none;
|
||
box-sizing: border-box;
|
||
padding: 0 var(--oj-pad-x);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 40px;
|
||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||
background: v-bind('theme.actionColor');
|
||
}
|
||
|
||
.ring-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.ring-label {
|
||
font-size: var(--oj-fs-meta);
|
||
line-height: 1.5;
|
||
color: v-bind('theme.textColor3');
|
||
}
|
||
|
||
/* 手机:老师很少在手机上看统计,只保证不坏 —— 筛选和数字行折行,整页自然滚动 */
|
||
@media (max-width: 767px) {
|
||
.stats:not(.modal) {
|
||
height: auto;
|
||
}
|
||
|
||
.filters,
|
||
.summary {
|
||
height: auto;
|
||
flex-wrap: wrap;
|
||
padding: 10px 14px;
|
||
row-gap: 10px;
|
||
white-space: normal;
|
||
}
|
||
|
||
.summary {
|
||
gap: 12px 24px;
|
||
}
|
||
|
||
.vsep,
|
||
.spacer {
|
||
display: none;
|
||
}
|
||
|
||
.w-class,
|
||
.w-user,
|
||
.w-period {
|
||
width: calc(50% - 4px);
|
||
}
|
||
|
||
.w-problem,
|
||
.w-dates {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|