按人看谁没做对是提交页「数据统计」的活;表里点一个班就去那边看。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2fddf4a011
commit
db493cebe4
5 files changed
+148
-321
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract"
|
||||
import { and, eq, isNotNull, sql } from "drizzle-orm"
|
||||
import { type ProblemStats } from "@oj2/contract"
|
||||
import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||
@@ -9,7 +9,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
|
||||
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
|
||||
import { accepted } from "../services/learning-stats"
|
||||
import { localTime } from "../time"
|
||||
import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers"
|
||||
import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
||||
|
||||
export const problemStatsRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -29,7 +29,6 @@ const EMPTY: ProblemStats = {
|
||||
me: null,
|
||||
myClass: null,
|
||||
classes: null,
|
||||
classDetail: null,
|
||||
}
|
||||
|
||||
// type 而不是 interface:db.execute 的泛型约束是 Record<string, unknown>,interface 不带索引签名
|
||||
@@ -41,14 +40,13 @@ type UserRow = {
|
||||
/** 做对之前(含做对那次)交了几次;没做对就是全部次数 */
|
||||
tries: number
|
||||
solved: boolean
|
||||
last_result: JudgeStatusValue
|
||||
last_time: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,
|
||||
* 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。
|
||||
* `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。
|
||||
* 题目页「统计」页签:讲的是**这道题**(难不难、几次做对、常错在哪),老师多一张各班的表。
|
||||
* 看谁没做对是提交页「数据统计」的活,这里只到班级这一层。
|
||||
* id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,拿 displayId 查会撞上
|
||||
* 同号的公开题(原来的「历年 AC 率」就是这么查错的)。
|
||||
*/
|
||||
problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
@@ -93,9 +91,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||
select s.user_id, u.username, u.class_name,
|
||||
count(*)::int as attempts,
|
||||
count(*) filter (where s.first_ac is null or s.create_time <= s.first_ac)::int as tries,
|
||||
(min(s.first_ac) is not null) as solved,
|
||||
(array_agg(s.result order by s.create_time desc))[1] as last_result,
|
||||
max(s.create_time) as last_time
|
||||
(min(s.first_ac) is not null) as solved
|
||||
from (
|
||||
select user_id, result, create_time,
|
||||
min(create_time) filter (where result in (${acceptedList}))
|
||||
@@ -157,7 +153,6 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||
const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : []
|
||||
|
||||
let classes: ProblemStats["classes"] = null
|
||||
let classDetail: ProblemClassDetail | null = null
|
||||
if (!inContest && isTeacherOrAbove(user)) {
|
||||
const byClass = new Map<string, UserRow[]>()
|
||||
for (const row of users) {
|
||||
@@ -172,27 +167,41 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||
peak.set(row.className!, { day: row.day, users: row.users })
|
||||
}
|
||||
}
|
||||
const lessonClasses = [...byClass]
|
||||
.filter(([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED)
|
||||
.map(([className, rows]) => ({
|
||||
const lessonClasses = [...byClass].filter(
|
||||
([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED,
|
||||
)
|
||||
// 花名册只数学生:老师、助教也可能挂着班级
|
||||
const sizes = lessonClasses.length
|
||||
? await db
|
||||
.select({ className: schema.user.className, value: count() })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
schema.user.className,
|
||||
lessonClasses.map(([className]) => className),
|
||||
),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className)
|
||||
: []
|
||||
classes = lessonClasses
|
||||
.map(([className, rows]) => {
|
||||
const top = failureRows
|
||||
.filter((row) => row.class_name === className)
|
||||
.sort((a, b) => b.n - a.n)[0]
|
||||
return {
|
||||
className,
|
||||
day: peak.get(className)!.day,
|
||||
tried: rows.length,
|
||||
solved: rows.filter((row) => row.solved).length,
|
||||
day: peak.get(className)!.day,
|
||||
}))
|
||||
.sort((a, b) => b.day.localeCompare(a.day))
|
||||
classes = lessonClasses
|
||||
// 地址里指定的班不在列表里(零星几个人做过)也照样给明细
|
||||
const wanted = c.req.query("className")?.trim()
|
||||
const selected = wanted && byClass.has(wanted) ? wanted : lessonClasses[0]?.className
|
||||
if (selected) {
|
||||
classDetail = await buildClassDetail(
|
||||
selected,
|
||||
peak.get(selected)!.day,
|
||||
byClass.get(selected)!,
|
||||
failureRows,
|
||||
)
|
||||
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
|
||||
topFailure: top ? { result: top.result, count: top.n } : null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.day.localeCompare(a.day))
|
||||
}
|
||||
|
||||
return success(c, {
|
||||
@@ -211,49 +220,5 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
|
||||
}
|
||||
: null,
|
||||
classes,
|
||||
classDetail,
|
||||
} satisfies ProblemStats)
|
||||
})
|
||||
|
||||
async function buildClassDetail(
|
||||
className: string,
|
||||
day: string,
|
||||
rows: UserRow[],
|
||||
failureRows: { class_name: string | null; result: JudgeStatusValue; n: number }[],
|
||||
): Promise<ProblemClassDetail> {
|
||||
// 花名册只数学生:老师、助教也可能挂着班级
|
||||
const roster = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.className, className),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
const triedIds = new Set(rows.map((row) => row.user_id))
|
||||
const name = (username: string | null) => stripClassPrefix(username ?? "", className)
|
||||
return {
|
||||
className,
|
||||
roster: roster.length,
|
||||
day,
|
||||
failures: failureRows
|
||||
.filter((row) => row.class_name === className)
|
||||
.map((row) => ({ result: row.result, count: row.n }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
unsolved: rows
|
||||
.filter((row) => !row.solved)
|
||||
.sort((a, b) => b.attempts - a.attempts)
|
||||
.map((row) => ({
|
||||
realName: name(row.username),
|
||||
attempts: row.attempts,
|
||||
lastResult: row.last_result,
|
||||
})),
|
||||
solved: rows
|
||||
.filter((row) => row.solved)
|
||||
.sort((a, b) => a.last_time.localeCompare(b.last_time))
|
||||
.map((row) => name(row.username)),
|
||||
untouched: roster.filter((row) => !triedIds.has(row.id)).map((row) => name(row.username)),
|
||||
}
|
||||
}
|
||||
@@ -115,14 +115,9 @@ export async function getProblem(problemID: string, contestID: string) {
|
||||
return problem
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。
|
||||
* className 只有老师用:看哪个班的明细,不填就是最近做过这题的班
|
||||
*/
|
||||
export function getProblemStats(problemID: number, className?: string) {
|
||||
return api.get<ProblemStats>(`problems/${problemID}/stats`, {
|
||||
params: className ? { className } : {},
|
||||
})
|
||||
/** 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查 */
|
||||
export function getProblemStats(problemID: number) {
|
||||
return api.get<ProblemStats>(`problems/${problemID}/stats`)
|
||||
}
|
||||
|
||||
export async function getSubmission(id: string): Promise<Submission> {
|
||||
|
||||
@@ -13,11 +13,10 @@ import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
|
||||
/**
|
||||
* 「统计」页签(设计稿「题目页统计重设计」:学生 A、老师 B)。
|
||||
*
|
||||
* 学生看的是「这题难不难 → 大家几次做对 → 常错在哪 → 你们班」,全部按人算;自己卡住了
|
||||
* 再多一张卡。老师看的是一个班:谁做对了、谁交了没对(卡在哪)、谁还没交 —— 原来工具栏上
|
||||
* 的「课堂统计」并进了这里。原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
||||
* 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次
|
||||
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
|
||||
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「数据统计」的活)。
|
||||
* 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
||||
*/
|
||||
const emit = defineEmits<{ openSubmissions: [] }>()
|
||||
|
||||
@@ -31,15 +30,12 @@ const tone = useTone()
|
||||
|
||||
const stats = ref<ProblemStats | null>(null)
|
||||
const failed = ref(false)
|
||||
const className = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
if (!problem.value) return
|
||||
failed.value = false
|
||||
try {
|
||||
const res = await getProblemStats(problem.value.id, className.value ?? undefined)
|
||||
stats.value = res
|
||||
if (res.classDetail) className.value = res.classDetail.className
|
||||
stats.value = await getProblemStats(problem.value.id)
|
||||
} catch {
|
||||
failed.value = true
|
||||
}
|
||||
@@ -47,13 +43,6 @@ async function load() {
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function changeClass(value: string) {
|
||||
className.value = value
|
||||
load()
|
||||
}
|
||||
|
||||
const teacherView = computed(() => stats.value?.classes != null)
|
||||
|
||||
function resultName(result: number) {
|
||||
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
||||
}
|
||||
@@ -139,53 +128,13 @@ const stuck = computed(() => {
|
||||
return me && !me.solved && me.attempts >= 3 ? me.attempts : 0
|
||||
})
|
||||
|
||||
// ---------- 老师 ----------
|
||||
// ---------- 老师:各班的表 ----------
|
||||
|
||||
const detail = computed(() => stats.value?.classDetail ?? null)
|
||||
|
||||
const classOptions = computed(() =>
|
||||
(stats.value?.classes ?? []).map((item) => ({
|
||||
label: `${classLabel(item.className)} · ${item.solved}/${item.tried}`,
|
||||
value: item.className,
|
||||
})),
|
||||
)
|
||||
|
||||
const classSegments = computed(() => {
|
||||
const d = detail.value
|
||||
if (!d) return []
|
||||
return [
|
||||
{ key: "solved", label: "做对", value: d.solved.length, color: theme.value.successColor },
|
||||
{ key: "unsolved", label: "交了没对", value: d.unsolved.length, color: theme.value.errorColor },
|
||||
{ key: "untouched", label: "没交", value: d.untouched.length, color: theme.value.dividerColor },
|
||||
]
|
||||
})
|
||||
|
||||
const classFailedTotal = computed(() =>
|
||||
(detail.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
|
||||
)
|
||||
|
||||
/** 这个班错得最多的和全站不一样时点出来:老师该看的就是这种差别 */
|
||||
const siteDiffers = computed(() => {
|
||||
const site = stats.value?.failures[0]
|
||||
const mine = detail.value?.failures[0]
|
||||
return site && mine && site.result !== mine.result ? resultName(site.result) : ""
|
||||
})
|
||||
|
||||
const showSolved = ref(false)
|
||||
const showUntouched = ref(false)
|
||||
|
||||
function openStatistics() {
|
||||
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
|
||||
function openStatistics(className?: string) {
|
||||
const href = router.resolve({
|
||||
name: "statistics",
|
||||
query: { problem: problem.value!._id, className: className.value ?? "", period: "all" },
|
||||
}).href
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
function openClassSubmissions() {
|
||||
const href = router.resolve({
|
||||
name: "submissions",
|
||||
query: { problem: problem.value!._id, className: className.value ?? "" },
|
||||
query: { problem: problem.value!._id, period: "all", ...(className ? { className } : {}) },
|
||||
}).href
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
@@ -251,105 +200,7 @@ function segmentWidth(value: number, total: number) {
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 老师:看一个班 ==================== -->
|
||||
<template v-else-if="teacherView">
|
||||
<div v-if="!detail" class="empty">
|
||||
<span class="empty-icon"><Icon icon="ph:users" :width="18" /></span>
|
||||
<b>还没有班级做过这道题</b>
|
||||
<n-text depth="3">布置到这节课之后,这里会按班显示谁做对了、谁还没交。</n-text>
|
||||
<n-button type="primary" secondary @click="router.push('/classroom')">
|
||||
去课堂看板布置 →
|
||||
</n-button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="class-head">
|
||||
<n-select
|
||||
:value="className"
|
||||
:options="classOptions"
|
||||
size="small"
|
||||
class="class-select"
|
||||
:consistent-menu-width="false"
|
||||
@update:value="changeClass"
|
||||
/>
|
||||
<n-text depth="3" class="small">{{ dayText(detail.day) }}做的</n-text>
|
||||
<span class="spacer"></span>
|
||||
<a href="#" class="link small" @click.prevent="openStatistics">
|
||||
在数据统计里看 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<span
|
||||
v-for="seg in classSegments"
|
||||
:key="seg.key"
|
||||
:style="{ width: segmentWidth(seg.value, detail.roster), background: seg.color }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span v-for="seg in classSegments" :key="seg.key">
|
||||
<i :style="{ background: seg.color }"></i>{{ seg.label }} <b>{{ seg.value }}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="classFailedTotal" class="line">
|
||||
<b>没通过的 {{ classFailedTotal }} 次:</b>
|
||||
<n-text depth="2">
|
||||
{{
|
||||
detail.failures
|
||||
.slice(0, 3)
|
||||
.map((item) => `${resultName(item.result)} ${item.count}`)
|
||||
.join(" · ")
|
||||
}}
|
||||
</n-text>
|
||||
<span class="spacer"></span>
|
||||
<n-text v-if="siteDiffers" depth="3" class="small">全站是{{ siteDiffers }}最多</n-text>
|
||||
</div>
|
||||
|
||||
<template v-if="detail.unsolved.length">
|
||||
<div class="line">
|
||||
<b>交了没对({{ detail.unsolved.length }} 人)</b>
|
||||
<span class="spacer"></span>
|
||||
<n-text depth="3" class="small">次数多的在前</n-text>
|
||||
</div>
|
||||
<div class="people">
|
||||
<div v-for="(item, index) in detail.unsolved" :key="index" class="person">
|
||||
<b>{{ item.realName }}</b>
|
||||
<n-text depth="3">{{ item.attempts }} 次</n-text>
|
||||
<span :style="{ color: barColor(item.lastResult) }">
|
||||
{{ resultName(item.lastResult) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="detail.solved.length" class="fold">
|
||||
<div class="fold-head" @click="showSolved = !showSolved">
|
||||
<b>做对 {{ detail.solved.length }}</b>
|
||||
<span class="fold-names">
|
||||
{{ showSolved ? "" : detail.solved.join("、") }}
|
||||
</span>
|
||||
<span class="link small">{{ showSolved ? "收起" : "展开" }}</span>
|
||||
</div>
|
||||
<div v-if="showSolved" class="fold-body">{{ detail.solved.join("、") }}</div>
|
||||
</div>
|
||||
<div v-if="detail.untouched.length" class="fold">
|
||||
<div class="fold-head" @click="showUntouched = !showUntouched">
|
||||
<b>没交 {{ detail.untouched.length }}</b>
|
||||
<span class="fold-names">{{ showUntouched ? "" : "展开看名单" }}</span>
|
||||
<span class="link small">{{ showUntouched ? "收起" : "展开" }}</span>
|
||||
</div>
|
||||
<div v-if="showUntouched" class="fold-body">{{ detail.untouched.join("、") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<a href="#" class="link small" @click.prevent="openClassSubmissions">
|
||||
看这个班这道题的提交 ›
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ==================== 学生 ==================== -->
|
||||
<!-- ==================== 这道题(学生、老师都看) ==================== -->
|
||||
<div v-else-if="!stats.tried" class="empty">
|
||||
<span class="empty-icon"><Icon icon="ph:sparkle" :width="18" /></span>
|
||||
<b>还没有人交过这道题</b>
|
||||
@@ -419,6 +270,66 @@ function segmentWidth(value: number, total: number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 老师:各班一行,只到班级这一层;按人看去数据统计 -->
|
||||
<div v-if="stats.classes" class="section">
|
||||
<div class="line">
|
||||
<b>各班做得怎样</b>
|
||||
<span class="spacer"></span>
|
||||
<n-text depth="3" class="small">同一天 5 人以上交过才算这个班做过</n-text>
|
||||
</div>
|
||||
<table v-if="stats.classes.length" class="classes">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>班级</th>
|
||||
<th>哪天做的</th>
|
||||
<th>做对 / 交过</th>
|
||||
<th>没交</th>
|
||||
<th>错得最多</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in stats.classes"
|
||||
:key="item.className"
|
||||
title="去数据统计看这个班的每个人"
|
||||
@click="openStatistics(item.className)"
|
||||
>
|
||||
<td>
|
||||
<b>{{ classLabel(item.className) }}</b>
|
||||
</td>
|
||||
<td>
|
||||
<n-text depth="2">{{ dayText(item.day) }}</n-text>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
:style="{
|
||||
color: item.solved < item.tried ? tone('error').color : undefined,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
>{{ item.solved }}</span
|
||||
>
|
||||
/ {{ item.tried }}
|
||||
</td>
|
||||
<td>
|
||||
<n-text depth="2">{{ Math.max(0, item.classSize - item.tried) }}</n-text>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="item.topFailure" :style="{ color: barColor(item.topFailure.result) }">
|
||||
{{ resultName(item.topFailure.result) }} {{ item.topFailure.count }}
|
||||
</span>
|
||||
<n-text v-else depth="3">—</n-text>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<n-text v-else depth="3" class="small">还没有哪个班一起做过这道题</n-text>
|
||||
<div class="foot">
|
||||
<a href="#" class="link small" @click.prevent="openStatistics()">
|
||||
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stats.myClass?.tried" class="class-box">
|
||||
<div class="line">
|
||||
<b>你们班 · {{ classLabel(stats.myClass.className) }}</b>
|
||||
@@ -652,65 +563,36 @@ function segmentWidth(value: number, total: number) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.class-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.class-select {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.people {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fold {
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.actionColor");
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fold-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fold-names {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.fold-body {
|
||||
padding: 0 12px 10px;
|
||||
line-height: 1.8;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.classes {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.classes 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");
|
||||
}
|
||||
|
||||
.classes td {
|
||||
padding: 8px 8px 8px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.classes tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.classes tbody tr:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
}
|
||||
</style>
|
||||
@@ -81,7 +81,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
|
||||
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
||||
*/
|
||||
export type { Tag, TagCategory } from "@oj2/contract"
|
||||
export type { ProblemStats, ProblemClassDetail } from "@oj2/contract"
|
||||
export type { ProblemStats } from "@oj2/contract"
|
||||
export type { TodaySubmissionStatistics } from "@oj2/contract"
|
||||
|
||||
export type {
|
||||
|
||||
@@ -449,23 +449,6 @@ export const problemAuthorSchema = z.object({
|
||||
|
||||
const failureCountSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
||||
|
||||
/** 老师选中一个班时的明细:谁做对了、谁交了没对(卡在哪)、谁还没交 */
|
||||
export const problemClassDetailSchema = z.object({
|
||||
className: z.string(),
|
||||
/** 班里的学生数(学生角色、没禁用) */
|
||||
roster: z.number().int(),
|
||||
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天)—— 「2025年12月5日做的」 */
|
||||
day: z.string(),
|
||||
failures: z.array(failureCountSchema),
|
||||
/** 交了没对,次数多的在前。名字是剥掉 ks<班级号> 前缀的那一段 */
|
||||
unsolved: z.array(
|
||||
z.object({ realName: z.string(), attempts: z.number().int(), lastResult: judgeStatusSchema }),
|
||||
),
|
||||
solved: z.array(z.string()),
|
||||
/** 班里还没交过的 */
|
||||
untouched: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数
|
||||
* (3092 是 219 条、202 个人),「通过率」是按提交算的,学生看了不知道这题难不难。
|
||||
@@ -491,20 +474,23 @@ export const problemStatsSchema = z.object({
|
||||
.nullable(),
|
||||
/**
|
||||
* 老师:一起做过这题的班(同一天至少 5 个人交过 —— 零星几个人自己刷到、补做的不算),
|
||||
* 按那天倒序。原来工具栏上的「课堂统计」并进了这里。学生、比赛里为 null
|
||||
* 按那天倒序。只到班级这一层:看谁没做对是提交页「数据统计」的活。学生、比赛里为 null
|
||||
*/
|
||||
classes: z
|
||||
.array(
|
||||
z.object({
|
||||
className: z.string(),
|
||||
/** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天) */
|
||||
day: z.string(),
|
||||
tried: z.number().int(),
|
||||
solved: z.number().int(),
|
||||
day: z.string(),
|
||||
/** 班里的学生数(学生角色、没禁用),没交的 = classSize - tried */
|
||||
classSize: z.number().int(),
|
||||
/** 这个班错得最多的那种,一次没错过为 null */
|
||||
topFailure: failureCountSchema.nullable(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
/** 老师选中的那个班(默认 classes 第一个)的明细 */
|
||||
classDetail: problemClassDetailSchema.nullable(),
|
||||
})
|
||||
|
||||
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
|
||||
@@ -523,4 +509,3 @@ export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
|
||||
export type SqlDisplayTable = z.infer<typeof sqlDisplayTableSchema>
|
||||
export type SqlDisplayColumn = z.infer<typeof sqlDisplayColumnSchema>
|
||||
export type ProblemStats = z.infer<typeof problemStatsSchema>
|
||||
export type ProblemClassDetail = z.infer<typeof problemClassDetailSchema>
|
||||
Reference in new issue
Block a user