Compare commits

...
4 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 99c158498c feat(提交列表): 老师也能在「全部 / 我的」之间切换,不用再靠「清空」退出「我的提交」
Deploy / deploy (push) Canceled after 0s
选「我的」时这节课、班级、学生三个框收起来(只看自己时后端本来就不认它们);
机房 1280 宽的屏上「今日统计」只留图标和数字,给这个切换腾地方。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:30:17 +08:00
xuyueandClaude Opus 5.5 8d1521f3b6 fix(统计): 统计弹框里几处数字对不上——完成度圆环把交了没对的也算成完成、请假隐藏的人还算在班级人数里
- 代码统计「班级完成度」圆环的已完成改成做完的人数,和上面「完成人数」一致
- 流程图统计「班级人数」减掉请假隐藏的,和完成度的分母一致
- 流程图统计打开就查、开着自动刷新(一天以内的时间段,30 秒一次),按钮带转圈
- 两个面板统计失败时在按钮旁说一句,不再无声无息停在旧数字上
- 没完成的人全被隐藏时不再显示「全都完成了」
- 流程图统计的「未完成」页签在全班做完后不再整个消失

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:14:34 +08:00
xuyueandClaude Opus 5.5 660f689a5c feat(提交列表): 重做成左边列表、右边代码,方向键一条条翻,不再开弹框
- 右栏常驻选中那条的代码:编译失败 / 运行时错误用中文说清第几行错了并标红,
  老师看得到逐个测试点,学生看别人的只显示结果
- 老师一键「这节课」:筛到正在上课的班、这节课的题、今天
- 「正在评分」的行自己变成结果;新交的不插队,顶上提示「有 N 条新提交」
- 按天分组、默认每页 30 条;状态筛选补全,语言补上 SQL;流程图的评分详情也放进右栏
- 课堂看板的格子和名字能直接点进这个学生今天的提交

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:08:49 +08:00
xuyueandClaude Opus 5.5 ea480d5e0c feat(提交列表): 老师也能重新判题;列表能按班级、一次按几道题筛,还带「通过几个测试点」
- 重新判题从只放超管改成老师及以上:前端一直给老师亮着按钮,点了只会「权限不足」
- /submissions、/flowcharts 加 className(user.class_name 精确匹配),题号可以一次填几道
- 列表每行带 caseSummary,口径和详情一致,在库里数完只回两个数
- 新增 GET /classroom/lesson:提交列表「这节课」按钮用,班级和题的取法同课堂看板

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:08:49 +08:00
28 changed files with 3068 additions and 1093 deletions

No files matched your search

+17
View File
@@ -5,6 +5,7 @@ import {
type ClassActivity, type ClassActivity,
type ClassBoard, type ClassBoard,
type ClassBoardStudent, type ClassBoardStudent,
type ClassLesson,
type ClassActivityProblem, type ClassActivityProblem,
type ClassComparison, type ClassComparison,
type ClassComparisonResponse, type ClassComparisonResponse,
@@ -671,6 +672,22 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉, * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
* 学生那边退回推断。 * 学生那边退回推断。
*/ */
classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
const className = c.req.query("className")?.trim() || (await suggestActiveClass())
if (!className)
return success(c, { className: null, source: null, problems: [] } satisfies ClassLesson)
// 和看板一样只回看今天:「这节课」不该冒出昨天的题
const lesson = await classLessonProblems(className, 1)
return success(c, {
className,
source: lesson.source,
problems: lesson.problems.map((problem) => ({
problemDisplayId: problem.displayId,
title: problem.title,
})),
} satisfies ClassLesson)
})
classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => { classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对") const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对")
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
+22 -2
View File
@@ -29,6 +29,7 @@ import {
isAdminRole, isAdminRole,
matchedUsers, matchedUsers,
asRecord, asRecord,
parseDisplayIds,
queryInteger, queryInteger,
rounded, rounded,
stripClassPrefix, stripClassPrefix,
@@ -178,12 +179,15 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
* 「查无此班」会变成「全站」。 * 「查无此班」会变成「全站」。
*/ */
async function flowchartProblemFilter(displayId: string) { async function flowchartProblemFilter(displayId: string) {
// 和代码提交列表一样可以一次筛几道
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
if (!lowered.length) return sql`false`
const problems = await db const problems = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
.from(schema.problem) .from(schema.problem)
.where( .where(
and( and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`, inArray(sql`lower(${schema.problem.displayId})`, lowered),
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空), // 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
// 比赛题的 _id 撞号是常态,不该被筛进来 // 比赛题的 _id 撞号是常态,不该被筛进来
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
@@ -197,6 +201,20 @@ async function flowchartProblemFilter(displayId: string) {
: sql`false` : sql`false`
} }
/** 按班级(user.class_name)筛,口径同 submission-statistics.ts 的 classNameFilter */
async function flowchartClassFilter(className: string) {
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, className))
return users.length
? inArray(
schema.flowchartSubmission.userId,
users.map((row) => row.id),
)
: sql`false`
}
async function flowchartUserFilter(username: string) { async function flowchartUserFilter(username: string) {
const ids = (await matchedUsers(username)).map((row) => row.id) const ids = (await matchedUsers(username)).map((row) => row.id)
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false` return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`
@@ -250,13 +268,15 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
return success(c, { results: [], total: 0 } satisfies FlowchartList) return success(c, { results: [], total: 0 } satisfies FlowchartList)
} }
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己 // 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
const className = c.req.query("className")?.trim()
const onlyMyself = const onlyMyself =
c.req.query("myself") === "1" || (!username && user.adminType === "Regular User") c.req.query("myself") === "1" || (!username && !className && user.adminType === "Regular User")
const filters: Array<SQL | undefined> = [] const filters: Array<SQL | undefined> = []
filters.push( filters.push(
...(await Promise.all([ ...(await Promise.all([
displayId ? flowchartProblemFilter(displayId) : undefined, displayId ? flowchartProblemFilter(displayId) : undefined,
!onlyMyself && username ? flowchartUserFilter(username) : undefined, !onlyMyself && username ? flowchartUserFilter(username) : undefined,
!onlyMyself && className ? flowchartClassFilter(className) : undefined,
])), ])),
) )
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id)) if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
+18
View File
@@ -171,3 +171,21 @@ export async function matchedUsers(username: string) {
.from(schema.user) .from(schema.user)
.where(ilike(schema.user.username, `%${username}%`)) .where(ilike(schema.user.username, `%${username}%`))
} }
/**
* 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 ——
* 老师在投影前手敲,不该因为打了个全角逗号就查不出来。
*/
export function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,,;;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
+24 -20
View File
@@ -21,7 +21,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
import { type ContestEnv } from "../services/contest" import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { localTime, dayStart } from "../time" import { localTime, dayStart } from "../time"
import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers" import { isAdminRole, matchedUsers, parseDisplayIds, rounded, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>() export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -138,24 +138,6 @@ function statisticsRange(c: { req: { query(name: string): string | undefined } }
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */ /** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20 const STATISTICS_MAX_PROBLEMS = 20
/**
* 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 ——
* 老师在投影前手敲,不该因为打了个全角逗号就查不出来。
*/
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,,;;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/** /**
* 按题号(展示用的 _id)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」—— * 按题号(展示用的 _id)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」——
* 否则教师打错一个字就会看到全站数据还以为是这几道题的。 * 否则教师打错一个字就会看到全站数据还以为是这几道题的。
@@ -327,8 +309,27 @@ export async function exactUsernameFilter(username: string) {
return row ? eq(schema.submission.userId, row.id) : sql`false` return row ? eq(schema.submission.userId, row.id) : sql`false`
} }
/**
* 按班级筛:`user.class_name` 精确匹配。原来列表只能拿用户名前缀(`ks253`)当班级,
* 那是「包含」匹配 —— ks253 会顺带捞进 ks2531 这类撞前缀的号,也要老师记得前缀怎么拼。
* 查无此班留恒假条件
*/
export async function classNameFilter(className: string) {
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, className))
return users.length
? inArray(
schema.submission.userId,
users.map((row) => row.id),
)
: sql`false`
}
/** /**
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。 * 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
* 可以一次填几道(`1021,1022`),**任一道**对上就算。
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能 * 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。 * 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
* *
@@ -337,12 +338,15 @@ export async function exactUsernameFilter(username: string) {
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。 * 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
*/ */
export async function problemFilter(displayId: string, contestId: number | null) { export async function problemFilter(displayId: string, contestId: number | null) {
// 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
if (!lowered.length) return sql`false`
const problems = await db const problems = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
.from(schema.problem) .from(schema.problem)
.where( .where(
and( and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`, inArray(sql`lower(${schema.problem.displayId})`, lowered),
contestId === null contestId === null
? isNull(schema.problem.contestId) ? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId), : eq(schema.problem.contestId, contestId),
+36 -3
View File
@@ -13,7 +13,7 @@ import {
import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm" import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware" import { optionalAuth, requireAuth, requireTeacher } from "../auth/middleware"
import type { AuthUser } from "../auth/session" import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
@@ -33,6 +33,7 @@ import { consumeToken } from "../services/throttling"
import { dayStart } from "../time" import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers" import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
import { import {
classNameFilter,
exactUsernameFilter, exactUsernameFilter,
problemFilter, problemFilter,
submissionStatisticsRoutes, submissionStatisticsRoutes,
@@ -208,7 +209,9 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
submissionRoutes.route("/", submissionStatisticsRoutes) submissionRoutes.route("/", submissionStatisticsRoutes)
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => { // 老师也能重判:旧栈只给超管,前端却一直给老师亮着按钮,老师点了只会「权限不足」。
// 流程图那边老师本来就能重新评分,两边对齐。比赛提交不在此列(下面 isNull(contestId))
submissionRoutes.post("/submissions/:id/rejudge", requireTeacher, async (c) => {
const [row] = await db const [row] = await db
.select({ .select({
id: schema.submission.id, id: schema.submission.id,
@@ -315,6 +318,19 @@ const submissionListColumns = {
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来 // 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜 // 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
problemsetId: schema.submission.problemsetId, problemsetId: schema.submission.problemsetId,
/**
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
* 在库里数完只回两个整数 —— info 带着每个点的 output_md5,整列拉回来只为数个数不值当
*/
casesTotal: sql<
number | null
>`case when jsonb_typeof(${schema.submission.info} -> 'data') = 'array'
then jsonb_array_length(${schema.submission.info} -> 'data') end`,
casesPassed: sql<
number | null
>`case when jsonb_typeof(${schema.submission.info} -> 'data') = 'array'
then (select count(*)::int from jsonb_array_elements(${schema.submission.info} -> 'data') as item
where item ->> 'result' = '0') end`,
}, },
problem: { problem: {
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
@@ -335,6 +351,16 @@ function caseSummary(submission: typeof schema.submission.$inferSelect) {
return { passed, total: data.length } return { passed, total: data.length }
} }
/** 列表行的 caseSummary:SQL 里数好的两个数,为 null 的情形和详情一致(SQL 题、没有逐点结果) */
function listCaseSummary(row: {
language: string
casesTotal: number | null
casesPassed: number | null
}) {
if (row.language === "SQL" || !row.casesTotal) return null
return { passed: row.casesPassed ?? 0, total: row.casesTotal }
}
async function submissionDetail(id: string, user: AuthUser) { async function submissionDetail(id: string, user: AuthUser) {
const [row] = await db const [row] = await db
.select({ .select({
@@ -490,10 +516,13 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const username = myself ? undefined : c.req.query("username")?.trim() const username = myself ? undefined : c.req.query("username")?.trim()
const result = c.req.query("result") const result = c.req.query("result")
const language = c.req.query("language")?.trim() const language = c.req.query("language")?.trim()
// 班级(user.class_name),和用户名可以同时用:「25计算机3班里名字带『王』的」
const className = myself ? undefined : c.req.query("className")?.trim()
const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)] const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)]
filters.push( filters.push(
...(await Promise.all([ ...(await Promise.all([
displayId ? problemFilter(displayId, null) : undefined, displayId ? problemFilter(displayId, null) : undefined,
className ? classNameFilter(className) : undefined,
username username
? c.req.query("exactUsername") === "1" ? c.req.query("exactUsername") === "1"
? exactUsernameFilter(username) ? exactUsernameFilter(username)
@@ -512,7 +541,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。 // (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.submission).where(where), db.select({ value: count() }).from(schema.submission).where(where),
paginateSubmissionRows(where, limit, offset, Boolean(username)), // 班级和用户名一样是「圈一小撮人」,走同一条圈选路(见 paginateSubmissionRows)
paginateSubmissionRows(where, limit, offset, Boolean(username || className)),
]) ])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([ const [joinTimes, problemsetTitles] = await Promise.all([
@@ -550,6 +580,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
title: problemsetTitles.get(submission.problemsetId)!, title: problemsetTitles.get(submission.problemsetId)!,
} }
: null, : null,
caseSummary: listCaseSummary(submission),
}) satisfies SubmissionListItem, }) satisfies SubmissionListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
@@ -617,6 +648,8 @@ submissionRoutes.get(
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null, problemSet: null,
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
caseSummary: null,
}) satisfies SubmissionListItem, }) satisfies SubmissionListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
+7
View File
@@ -10,6 +10,7 @@ import {
type TutorialSummary, type TutorialSummary,
type ClassActivity, type ClassActivity,
type ClassBoard, type ClassBoard,
type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassComparisonResponse, type ClassComparisonResponse,
type ClassRankItem, type ClassRankItem,
@@ -223,6 +224,11 @@ export function getClassBoard(className?: string) {
return api.get<ClassBoard>("classroom/board", { params: { className } }) return api.get<ClassBoard>("classroom/board", { params: { className } })
} }
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
export function getClassLesson(className?: string) {
return api.get<ClassLesson>("classroom/lesson", { params: { className } })
}
/** 给这个班布置今天的题,空数组 = 清掉 */ /** 给这个班布置今天的题,空数组 = 清掉 */
export function setClassLesson(className: string, problemDisplayIds: string[]) { export function setClassLesson(className: string, problemDisplayIds: string[]) {
return api.put<null>("classroom/lesson", { className, problemDisplayIds }) return api.put<null>("classroom/lesson", { className, problemDisplayIds })
@@ -412,6 +418,7 @@ export function getFlowchartSubmission(id: string) {
export function getFlowchartSubmissions(params: { export function getFlowchartSubmissions(params: {
username?: string username?: string
className?: string
problemDisplayId?: string problemDisplayId?: string
myself?: string myself?: string
offset?: number offset?: number
+50 -1
View File
@@ -198,6 +198,24 @@ const GROUP_LABEL: Record<
working: { text: "在做", type: "info" }, working: { text: "在做", type: "info" },
done: { text: "做完了", type: "success" }, done: { text: "做完了", type: "success" },
} }
const router = useRouter()
/**
* 点格子 / 名字到提交列表看他今天交的代码。新标签打开:看板多半正投在屏幕上,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)
*/
function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string) {
return router.resolve({
name: "submissions",
query: {
username: student.username,
exactUsername: "1",
today: "1",
...(problemDisplayId ? { problem: problemDisplayId } : {}),
},
}).href
}
</script> </script>
<template> <template>
@@ -331,7 +349,18 @@ const GROUP_LABEL: Record<
</thead> </thead>
<tbody> <tbody>
<tr v-for="student in students" :key="student.userId"> <tr v-for="student in students" :key="student.userId">
<td class="name">{{ nameOf(student) }}</td> <td class="name">
<a
v-if="student.lastSubmitAt"
class="cell-link"
:href="submissionsHref(student)"
target="_blank"
:title="`看 ${nameOf(student)} 今天交的全部`"
>
{{ nameOf(student) }}
</a>
<template v-else>{{ nameOf(student) }}</template>
</td>
<td> <td>
<n-tag <n-tag
size="small" size="small"
@@ -350,7 +379,16 @@ const GROUP_LABEL: Record<
</n-tag> </n-tag>
</td> </td>
<td v-for="(cell, i) in student.cells" :key="i" :class="cellClass(cell)"> <td v-for="(cell, i) in student.cells" :key="i" :class="cellClass(cell)">
<a
v-if="cell.attempts"
class="cell-link"
:href="submissionsHref(student, board.problems[i]!.problemDisplayId)"
target="_blank"
:title="`看 ${nameOf(student)} 今天在这道题上交的 ${cell.attempts} 次`"
>
{{ cellText(cell) }} {{ cellText(cell) }}
</a>
<template v-else>{{ cellText(cell) }}</template>
</td> </td>
<td class="meta"> <td class="meta">
{{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }} {{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }}
@@ -371,6 +409,17 @@ const GROUP_LABEL: Record<
</template> </template>
<style scoped> <style scoped>
/* 格子、名字能点进提交列表,但别长得像一片蓝色链接 —— 投影上一眼看的是颜色 */
.cell-link {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.cell-link:hover {
text-decoration: underline;
}
.board { .board {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
@@ -1,53 +0,0 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props {
type: "题目" | "用户"
username?: string
}
const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"])
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
// 也显示出按钮,点了却什么都不发生
const showFilterClass = computed(() => {
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
})
function filterClass() {
const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : ""
if (!classname) return
emits("filterClass", classname)
}
</script>
<template>
<n-flex align="center">
<n-button text type="info" @click="$emit('click')">
<slot></slot>
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="$emit('search')">
<template #icon>
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
</template>
</n-button>
</template>
{{ "搜索" + props.type }}
</n-tooltip>
<n-tooltip v-if="showFilterClass">
<template #trigger>
<n-button text @click="filterClass">
<template #icon>
<Icon icon="streamline-emojis:scroll"></Icon>
</template>
</n-button>
</template>
筛选班级
</n-tooltip>
</n-flex>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useDark } from "@vueuse/core"
/**
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
*
* 不用 n-code 自带的 show-line-numbers:它没法标出某一行。行高写死成一个值,
* 标红的那条底色按行号算 top,和代码逐行对齐。
*/
const props = withDefaults(
defineProps<{
code: string
/** highlight.js 的语言名(LANGUAGE_FORMAT_VALUE) */
language: string
/** 要标红的行,1 起 */
markLine?: number | null
fontSize?: number
lineHeight?: number
}>(),
{ markLine: null, fontSize: 18, lineHeight: 30 },
)
const theme = useThemeVars()
const isDark = useDark()
const PADDING = 8
const lineCount = computed(() => Math.max(1, props.code.split("\n").length))
const markStyle = computed(() =>
props.markLine && props.markLine <= lineCount.value
? {
top: `${PADDING + (props.markLine - 1) * props.lineHeight}px`,
height: `${props.lineHeight}px`,
}
: null,
)
const scroller = useTemplateRef<HTMLElement>("scroller")
// 换了一条就回到顶上;有标红行的,滚到那一行附近
watch(
() => [props.code, props.markLine] as const,
async () => {
await nextTick()
const el = scroller.value
if (!el) return
const target = props.markLine ? (props.markLine - 3) * props.lineHeight : 0
el.scrollTop = Math.max(0, target)
el.scrollLeft = 0
},
{ immediate: true },
)
</script>
<template>
<div
ref="scroller"
class="code-view"
:style="{
'--fs': `${fontSize}px`,
'--lh': `${lineHeight}px`,
'--pad': `${PADDING}px`,
}"
>
<div class="gutter">
<div v-for="n in lineCount" :key="n" :class="{ marked: n === markLine }">{{ n }}</div>
</div>
<div class="body">
<div v-if="markStyle" class="mark" :style="markStyle"></div>
<n-code :code="code" :language="language" />
</div>
</div>
</template>
<style scoped>
.code-view {
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: flex-start;
overflow: auto;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
font-family: Consolas, Monaco, monospace;
font-size: var(--fs);
line-height: var(--lh);
}
.gutter {
position: sticky;
left: 0;
z-index: 1;
flex: none;
min-width: 40px;
min-height: 100%;
box-sizing: border-box;
padding: var(--pad) 10px var(--pad) 8px;
text-align: right;
color: v-bind("theme.textColor3");
background: v-bind("isDark ? 'rgba(255,255,255,0.03)' : '#fafafb'");
border-right: 1px solid v-bind("theme.dividerColor");
user-select: none;
}
.gutter .marked {
color: v-bind("theme.errorColor");
font-weight: 700;
}
.body {
position: relative;
flex: 1 0 auto;
padding: var(--pad) 14px;
}
.mark {
position: absolute;
left: 0;
right: 0;
background: v-bind("isDark ? 'rgba(232, 128, 128, 0.16)' : '#fdf0f2'");
}
.body :deep(.n-code) {
position: relative;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.body :deep(pre) {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
white-space: pre;
}
</style>
@@ -1,31 +0,0 @@
<template>
<n-button v-if="flowchart.showLink" type="info" text @click="handleClick">
{{ flowchart.id.slice(0, 12) }}
</n-button>
<!-- 没权限时不能挂 @click:后端 GET /flowcharts/:id 会以 404 挡下,
前端只会得到一个静默失败的空白面板 -->
<n-text v-else class="flowchart-id" depth="3">
{{ flowchart.id.slice(0, 12) }}
</n-text>
</template>
<script setup lang="ts">
import type { FlowchartSubmissionListItem } from "utils/types"
interface Props {
flowchart: FlowchartSubmissionListItem
}
const props = defineProps<Props>()
const emit = defineEmits<{
showDetail: [id: string]
}>()
// showLink 由后端逐行下发(见 routes/flowchart.ts 的 canView),与
// GET /flowcharts/:id 的放行条件同源。原来前端自己按「超管或本人」算了一遍,
// 既漏了教师,也和后端对不上。
function handleClick() {
emit("showDetail", props.flowchart.id)
}
</script>
<style scoped></style>
@@ -0,0 +1,470 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getFlowchartSubmission } from "oj/api"
import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
import type { FlowchartSubmission, FlowchartSubmissionListItem } from "utils/types"
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone } from "../composables/tone"
import { submissionTimeText } from "../utils"
import FlowchartState from "./FlowchartState.vue"
import UserName from "./UserName.vue"
/**
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
* 1000px 宽的弹框(FlowchartScoreDetail),翻全班的图时一样是「开 → 关 → 开」。
*/
const props = defineProps<{
row: FlowchartSubmissionListItem
teacher: boolean
position: { index: number; count: number } | null
/** 手机上的整屏详情:头部折行、图和评分上下排、不提方向键 */
narrow?: boolean
}>()
const emit = defineEmits<{
filterUser: [username: string]
filterProblem: [displayId: string]
openProblem: [row: FlowchartSubmissionListItem]
retry: [id: string]
}>()
const theme = useThemeVars()
const tone = useTone()
const { renderError, renderFlowchart } = useMermaid()
const detail = ref<FlowchartSubmission | null>(null)
const loading = ref(false)
const failed = ref(false)
const cache = new Map<string, FlowchartSubmission>()
const graph = useTemplateRef<HTMLElement>("graph")
const bigGraph = useTemplateRef<HTMLElement>("bigGraph")
const bigOpen = ref(false)
const evaluating = computed(
() =>
props.row.status === FlowchartSubmissionStatus.PENDING ||
props.row.status === FlowchartSubmissionStatus.PROCESSING,
)
async function load() {
const row = props.row
failed.value = false
bigOpen.value = false
if (!row.showLink) {
detail.value = null
return
}
const key = `${row.id}:${row.status}`
let res = cache.get(key)
if (!res) {
loading.value = true
try {
res = await getFlowchartSubmission(row.id)
if (!evaluating.value) cache.set(key, res)
} catch {
if (props.row.id === row.id) failed.value = true
return
} finally {
if (props.row.id === row.id) loading.value = false
}
}
if (props.row.id !== row.id) return
detail.value = res
await nextTick()
await renderFlowchart(graph.value, res.mermaidCode)
}
watch(() => [props.row.id, props.row.status, props.row.showLink] as const, load, {
immediate: true,
})
watch(bigOpen, async (open) => {
if (!open || !detail.value) return
await nextTick()
await renderFlowchart(bigGraph.value, detail.value.mermaidCode)
})
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
/**
* 评分项明细。内容是 AI 模型原样吐出的 JSON,后端不校验形状,只能按约定断言,
* 字段缺失时用 0 / 空串兜底。jsonb 不保留键序,按固定顺序排(逻辑正确性在最前)
*/
const criteria = computed(() => {
const raw = shown.value?.aiCriteriaDetails ?? {}
const parsed = Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{ score: number; max: number; comment: string }>
return [key, { score: item.score ?? 0, max: item.max ?? 0, comment: item.comment ?? "" }]
}),
)
return sortFlowchartCriteria(parsed)
})
function barColor(score: number, max: number) {
const pct = max ? score / max : 0
if (pct >= 0.8) return tone("success").solid
if (pct >= 0.6) return tone("info").solid
if (pct >= 0.4) return tone("warning").solid
return tone("error").solid
}
const suggestions = computed(() =>
(shown.value?.aiSuggestions ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
)
const canRetry = computed(
() =>
props.teacher &&
(props.row.status === FlowchartSubmissionStatus.COMPLETED ||
props.row.status === FlowchartSubmissionStatus.FAILED),
)
</script>
<template>
<section class="pane" :class="{ narrow }">
<div class="head">
<div class="who">
<UserName :username="row.username" />
<button v-if="teacher" class="filter-pill" @click="emit('filterUser', row.username)">
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他
</button>
</div>
<span class="dot">·</span>
<a class="problem" href="#" @click.prevent="emit('openProblem', row)">
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
v-if="teacher"
class="filter-pill"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
</button>
<div class="spacer"></div>
<n-button
v-if="shown && !renderError && shown.mermaidCode"
size="small"
@click="bigOpen = true"
>
<template #icon><Icon icon="ph:corners-out" /></template>
查看大图
</n-button>
<n-button v-if="teacher" size="small" :disabled="!canRetry" @click="emit('retry', row.id)">
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新评分
</n-button>
</div>
<div class="meta">
<FlowchartState :status="row.status" :grade="row.aiGrade" :score="row.aiScore" />
<span>{{ submissionTimeText(row.createTime, true) }}</span>
<span v-if="row.processingTime">评分用时 {{ row.processingTime.toFixed(1) }} 秒</span>
</div>
<div v-if="!row.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<span class="locked-title">别人画的流程图看不到</span>
</div>
<div v-else class="body">
<div class="graph-box">
<n-alert v-if="renderError" type="error" title="流程图画不出来">
{{ renderError }}
</n-alert>
<div v-show="!renderError" ref="graph" class="graph"></div>
<n-spin v-if="loading && !shown" size="small" />
<span v-if="failed" class="muted"
>没拉下来,<a href="#" @click.prevent="load">再试一次</a></span
>
</div>
<div class="score-box">
<template v-if="evaluating">
<n-spin size="small" />
<span class="muted">AI 正在评分,一般几秒钟</span>
</template>
<template v-else-if="row.status === FlowchartSubmissionStatus.FAILED">
<span class="muted">这次评分失败了{{ teacher ? ",可以点「重新评分」" : "" }}</span>
</template>
<template v-else-if="shown">
<div v-for="[name, item] in criteria" :key="name" class="crit">
<div class="crit-head">
<b>{{ name }}</b>
<div class="spacer"></div>
<span class="num">{{ item.score }} / {{ item.max }}</span>
</div>
<div class="bar">
<div
class="bar-fill"
:style="{
width: `${item.max ? Math.min(100, (item.score / item.max) * 100) : 0}%`,
background: barColor(item.score, item.max),
}"
></div>
</div>
<div v-if="item.comment" class="muted small">{{ item.comment }}</div>
</div>
<div v-if="shown.aiFeedback" class="note">{{ shown.aiFeedback }}</div>
<div v-if="suggestions.length" class="note">
<b>改进建议:</b>
<div v-for="(line, i) in suggestions" :key="i">{{ line }}</div>
</div>
</template>
</div>
</div>
<div v-if="!narrow" class="foot">
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
<span>换上一张 / 下一张</span>
<div class="spacer"></div>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
</div>
<n-modal v-model:show="bigOpen" preset="card" title="流程图" style="width: 90vw">
<div ref="bigGraph" class="big-graph"></div>
</n-modal>
</section>
</template>
<style scoped>
.pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: v-bind("theme.bodyColor");
}
.head {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: 15px;
}
.narrow {
padding: 12px 14px;
overflow-y: auto;
}
.narrow .head {
flex-wrap: wrap;
row-gap: 8px;
}
.narrow .head .spacer {
flex-basis: 100%;
}
.narrow .body {
flex-direction: column;
flex: none;
}
.narrow .graph-box {
flex: none;
height: 320px;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dot,
.problem-id,
.muted {
color: v-bind("theme.textColor3");
}
.problem {
color: v-bind("theme.textColor1");
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-pill {
flex: none;
height: 22px;
padding: 0 7px;
border: 1px solid v-bind("tone('success').background");
border-radius: 11px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("tone('success').color");
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.spacer {
flex: 1 1 0;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.body {
flex: 1 1 0;
min-height: 0;
display: flex;
gap: 12px;
}
.graph-box,
.score-box,
.locked {
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.graph-box {
flex: 0 0 42%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
padding: 8px;
box-sizing: border-box;
}
.graph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.graph :deep(svg) {
max-width: 100%;
max-height: 100%;
}
.score-box {
flex: 1 1 0;
min-width: 0;
overflow: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.crit {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.crit-head {
display: flex;
align-items: center;
}
.num {
font-variant-numeric: tabular-nums;
}
.bar {
height: 6px;
border-radius: 3px;
background: v-bind("theme.dividerColor");
}
.bar-fill {
height: 6px;
border-radius: 3px;
}
.small {
font-size: 12px;
}
.note {
font-size: 13px;
line-height: 1.6;
border-top: 1px solid v-bind("theme.dividerColor");
padding-top: 10px;
}
.locked {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-style: dashed;
}
.lock-icon {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.locked-title {
font-size: 16px;
font-weight: 600;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.keys {
display: inline-flex;
gap: 3px;
}
kbd {
min-width: 18px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
border: 1px solid v-bind("theme.borderColor");
border-radius: 3px;
background: v-bind("theme.cardColor");
font: inherit;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.big-graph {
display: flex;
justify-content: center;
overflow: auto;
max-height: 80vh;
}
</style>
@@ -1,226 +0,0 @@
<template>
<n-grid v-if="submission" :cols="5" :x-gap="16">
<!-- 左侧:流程图预览区域 -->
<n-gi :span="3">
<n-card title="流程图预览">
<template #header-extra>
<n-button
v-if="!renderError && submission?.mermaidCode"
quaternary
size="small"
@click="showLargeImage = true"
>
<template #icon>
<Icon icon="ph:corners-out" />
</template>
查看大图
</n-button>
</template>
<div class="flowchart">
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</n-alert>
<Teleport to="body" :disabled="!showLargeImage">
<div
v-show="!renderError"
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
ref="mermaidContainer"
></div>
<div v-if="showLargeImage" class="fullscreen-toolbar">
<n-button secondary round @click="showLargeImage = false">
<template #icon>
<Icon icon="ph:corners-in" />
</template>
退出大图
</n-button>
</div>
</Teleport>
</div>
</n-card>
</n-gi>
<!-- 右侧:评分详情区域 -->
<n-gi :span="2">
<!-- AI反馈 -->
<n-card v-if="submission.aiFeedback" size="small" title="AI反馈" style="margin-bottom: 16px">
<n-text>{{ submission.aiFeedback }}</n-text>
</n-card>
<!-- 改进建议 -->
<n-card
v-if="suggestionLines.length"
size="small"
title="改进建议"
style="margin-bottom: 16px"
>
<n-flex vertical :size="6">
<n-text v-for="(suggestion, index) in suggestionLines" :key="`${index}-${suggestion}`">
{{ suggestion }}
</n-text>
</n-flex>
</n-card>
<!-- 详细评分 -->
<n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
<!-- 评分项标题和分数 -->
<n-flex justify="space-between" align="center" style="margin-bottom: 4px">
<n-text strong>{{ key }}</n-text>
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
{{ detail.score || 0 }}分 / {{ detail.max }}分
</n-tag>
</n-flex>
<!-- 评分项详细说明 -->
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
{{ detail.comment }}
</n-text>
</div>
</n-card>
</n-gi>
</n-grid>
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { FlowchartSubmission } from "utils/types"
import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
interface Props {
submissionId: string
}
const props = defineProps<Props>()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderError, renderFlowchart } = useMermaid()
const submission = ref<FlowchartSubmission | null>(null)
/**
* 评分项明细。契约里是 `Record<string, unknown>` —— 内容是 AI 模型原样吐出的 JSON,
* 后端不校验形状,所以这里只能按约定断言,字段缺失时用 0 / 空串兜底。
*/
const criteriaDetails = computed<Record<string, { score: number; max: number; comment: string }>>(
() => {
const raw = submission.value?.aiCriteriaDetails ?? {}
return Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{
score: number
max: number
comment: string
}>
return [
key,
{
score: item.score ?? 0,
max: item.max ?? 0,
comment: item.comment ?? "",
},
]
}),
)
},
)
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
const loading = ref(false)
const rendering = ref(false)
const showLargeImage = ref(false)
const suggestionLines = computed(() => splitSuggestionLines(submission.value?.aiSuggestions))
function splitSuggestionLines(suggestions?: string | null) {
return suggestions
? suggestions
.split("\n")
.map((suggestion) => suggestion.trim())
.filter(Boolean)
: []
}
function getPercentType(percent: number) {
if (percent >= 0.8) return "primary"
else if (percent >= 0.6) return "info"
else if (percent >= 0.4) return "warning"
return "error"
}
async function loadSubmission() {
if (!props.submissionId) return
showLargeImage.value = false
loading.value = true
try {
const { getFlowchartSubmission } = await import("oj/api")
const res = await getFlowchartSubmission(props.submissionId)
submission.value = res
// 渲染流程图
if (submission.value?.mermaidCode) {
rendering.value = true
await nextTick()
await renderFlowchart(mermaidContainer.value, submission.value.mermaidCode)
rendering.value = false
}
} catch (error) {
console.error("Failed to load submission:", error)
} finally {
loading.value = false
}
}
watch(() => props.submissionId, loadSubmission, { immediate: true })
</script>
<style scoped>
.flowchart {
height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
.flowchart-fullscreen {
position: fixed;
inset: 0;
z-index: 4000;
width: 100vw;
height: 100vh;
padding: 32px;
box-sizing: border-box;
background: #ffffff;
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
display: block;
overflow: auto;
}
.fullscreen-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 4001;
}
/* 确保 SVG 图表占满容器 */
:deep(.flowchart > svg) {
height: 100%;
}
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
:deep(.flowchart-fullscreen > svg) {
display: block;
margin: 0 auto;
width: auto;
height: auto;
max-width: none;
}
.loading-container {
min-height: 600px;
display: flex;
justify-content: center;
align-items: center;
}
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
/**
* 流程图提交在列表里的那个胶囊:评完的直接是「A 级 86 分」,没评完的是
* 排队中 / 评分中 / 评分失败。原来等级、分数、状态分三列,老师要横着对。
*/
const props = defineProps<{
status: number
grade: string | null
score: number | null
}>()
const tone = useTone()
const GRADE_TONE: Record<string, Tone> = { S: "success", A: "info", B: "warning", C: "error" }
const view = computed(() => {
if (props.status === FlowchartSubmissionStatus.COMPLETED && props.grade) {
return { tone: GRADE_TONE[props.grade] ?? "default", grade: props.grade, text: "" }
}
if (props.status === FlowchartSubmissionStatus.FAILED) {
return { tone: "error" as const, grade: "", text: "评分失败" }
}
if (props.status === FlowchartSubmissionStatus.PROCESSING) {
return { tone: "info" as const, grade: "", text: "评分中" }
}
return { tone: "default" as const, grade: "", text: "排队中" }
})
const colors = computed(() => tone(view.value.tone))
</script>
<template>
<span class="pill" :style="{ color: colors.color, background: colors.background }">
<template v-if="view.grade">
<b>{{ view.grade }} 级</b><span class="score">{{ score ?? 0 }} 分</span>
</template>
<template v-else>{{ view.text }}</template>
</span>
</template>
<style scoped>
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
flex: none;
}
.score {
font-weight: 400;
}
</style>
@@ -1,26 +0,0 @@
<template>
<n-text :type="gradeType(grade)">
<span>{{ score }}</span>
<span>({{ grade }})</span>
</n-text>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
defineProps<{
score: number
grade: Grade
}>()
function gradeType(grade: Grade) {
return (
{
"": "default",
S: "success",
A: "info",
B: "warning",
C: "error",
} as const
)[grade]
}
</script>
<style scoped></style>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
const props = defineProps<{ result: SUBMISSION_RESULT }>()
const tone = useTone()
/**
* 判题结果的胶囊。颜色按「学生该怎么想」分三档,而不是照搬 JUDGE_STATUS 的 type:
* 红 = 答案不对(答案错误、超时、超内存、系统错误),橙 = 程序本身有问题
* (编译失败、运行时错误)或对了一半(部分正确、语法未通过),蓝 = 还在判。
*/
const TONE: Partial<Record<number, Tone>> = {
[SubmissionStatus.accepted]: "success",
[SubmissionStatus.wrong_answer]: "error",
[SubmissionStatus.cpu_time_limit_exceeded]: "error",
[SubmissionStatus.real_time_limit_exceeded]: "error",
[SubmissionStatus.memory_limit_exceeded]: "error",
[SubmissionStatus.system_error]: "error",
[SubmissionStatus.compile_error]: "warning",
[SubmissionStatus.runtime_error]: "warning",
[SubmissionStatus.partial_accepted]: "warning",
[SubmissionStatus.ast_check_failed]: "warning",
[SubmissionStatus.pending]: "info",
[SubmissionStatus.judging]: "info",
[SubmissionStatus.submitting]: "info",
}
const judging = computed(
() =>
props.result === SubmissionStatus.pending ||
props.result === SubmissionStatus.judging ||
props.result === SubmissionStatus.submitting,
)
const icon = computed(() => {
const r = props.result
if (r === SubmissionStatus.accepted || r === SubmissionStatus.ast_check_failed) {
return "ph:check-bold"
}
if (
r === SubmissionStatus.compile_error ||
r === SubmissionStatus.runtime_error ||
r === SubmissionStatus.partial_accepted
) {
return "ph:warning-bold"
}
if (
r === SubmissionStatus.cpu_time_limit_exceeded ||
r === SubmissionStatus.real_time_limit_exceeded
) {
return "ph:clock-bold"
}
return "ph:x-bold"
})
const colors = computed(() => tone(TONE[props.result] ?? "default"))
</script>
<template>
<span
class="pill"
:style="{ color: colors.color, background: colors.background }"
:title="JUDGE_STATUS[result]?.title"
>
<span v-if="judging" class="spinner" :style="{ borderTopColor: colors.color }"></span>
<Icon v-else :icon="icon" :width="12" />
{{ JUDGE_STATUS[result]?.name ?? "未知状态" }}
</span>
</template>
<style scoped>
.pill {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 9px 0 7px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
flex: none;
}
.spinner {
width: 10px;
height: 10px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid rgba(128, 128, 128, 0.35);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,50 +0,0 @@
<template>
<n-flex v-if="props.submission.showLink" align="center">
<n-button text type="info" @click="$emit('showCode')">
{{ props.submission.id.slice(0, 12) }}
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="goto">
<template #icon>
<Icon icon="catppuccin:folder-debug"></Icon>
</template>
</n-button>
</template>
查看测试详情
</n-tooltip>
</n-flex>
<n-flex v-else-if="isOwnSubmission" align="center">
<span>{{ props.submission.id.slice(0, 12) }}</span>
<n-tooltip>
<template #trigger>
<n-button text>
<template #icon>
<Icon icon="catppuccin:lock"></Icon>
</template>
</n-button>
</template>
这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。
</n-tooltip>
</n-flex>
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useUserStore } from "shared/store/user"
import type { SubmissionListItem } from "utils/types"
interface Props {
submission: SubmissionListItem
}
const props = defineProps<Props>()
defineEmits(["showCode"])
const userStore = useUserStore()
const isOwnSubmission = computed(() => userStore.profile?.user?.id === props.submission.userId)
function goto() {
window.open("/submission/" + props.submission.id, "_blank")
}
</script>
@@ -0,0 +1,629 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useUserStore } from "shared/store/user"
import {
JUDGE_STATUS,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
SubmissionStatus,
} from "utils/constants"
import {
submissionCaseResults,
submissionMemoryFormat,
submissionTimeFormat,
} from "utils/functions"
import type { Submission, SubmissionListItem } from "utils/types"
import { useCopySubmission } from "../composables/copySubmission"
import { useTone } from "../composables/tone"
import { submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import StatusPill from "./StatusPill.vue"
import UserName from "./UserName.vue"
/**
* 提交列表的右栏:选中那一条的代码和结果(设计稿「提交列表重设计 · 定稿」)。
* 原来要点开一个弹框看,老师翻全班代码时是「点开 → 看 → 关掉 → 点下一行」。
*/
const props = defineProps<{
row: SubmissionListItem
/** 老师那套:「只看他」「只看这题」、重新判题、逐个测试点 */
teacher: boolean
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
contest: boolean
/** 右下角的「本页第 i / n 条」 */
position: { index: number; count: number } | null
/** 手机上的整屏详情:头部折行、不提方向键 */
narrow?: boolean
}>()
const emit = defineEmits<{
filterUser: [username: string]
filterProblem: [displayId: string]
openProblem: [row: SubmissionListItem]
rejudge: [id: string]
}>()
const userStore = useUserStore()
const theme = useThemeVars()
// 暗色下测试点方块的底色是主题色本身(浅),白字就看不清了
const isDark = useDark()
const tone = useTone()
const { copyToCat, copyToProblem } = useCopySubmission()
/**
* 详情按「id + 结果」缓存:同一条判完之前结果会变,变了要重拉;判完的代码不会再变,
* 老师在几行之间来回按方向键时不用每次都等网络。
*/
const cache = new Map<string, Submission>()
const detail = ref<Submission | null>(null)
const loading = ref(false)
const failed = ref(false)
const judging = computed(
() =>
props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging,
)
async function load() {
const row = props.row
failed.value = false
if (!row.showLink) {
detail.value = null
return
}
const key = `${row.id}:${row.result}`
const hit = cache.get(key)
if (hit) {
detail.value = hit
return
}
// 换行时先别清空:接口一般几十毫秒就回来,清了会闪一下白
loading.value = true
try {
const res = await getSubmission(row.id)
if (props.row.id !== row.id) return
if (!judging.value) cache.set(key, res)
detail.value = res
} catch {
if (props.row.id === row.id) failed.value = true
} finally {
if (props.row.id === row.id) loading.value = false
}
}
watch(() => [props.row.id, props.row.result, props.row.showLink] as const, load, {
immediate: true,
})
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
const locked = computed(() => {
if (props.row.showLink) return null
if (!userStore.isAuthed) return "anon" as const
if (props.row.userId === userStore.user?.id) return "problemset" as const
return "others" as const
})
/** 逐个测试点。只有管理员拿得到 info(每个点带 output_md5),学生只有 caseSummary 两个数 */
const cases = computed(() => submissionCaseResults(shown.value?.info))
function stripJudgePath(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
}
/**
* 编译失败 / 运行时错误说清楚「错在第几行、为什么」—— 翻译表和题目页结果区同一份
* (pythonError.ts / runtimeError.ts)。原来的看代码弹框压根不显示报错。
*/
const explain = computed(() => {
const s = shown.value
if (!s) return null
const info = s.statisticInfo
const err = info?.err_info ? stripJudgePath(info.err_info) : ""
switch (s.result) {
case SubmissionStatus.compile_error: {
const ex = s.language === "Python" && err ? explainPythonCompileError(err) : null
if (ex) return { line: ex.line, text: ex.message, raw: err }
return { line: null, text: "编译没通过,看看下面的原始报错。", raw: err }
}
case SubmissionStatus.runtime_error: {
const rt = info?.runtime_error
if (rt) return { line: rt.line ?? null, text: explainRuntimeError(rt), raw: err }
return { line: null, text: "程序运行到一半出错,停下来了。", raw: err }
}
case SubmissionStatus.cpu_time_limit_exceeded:
case SubmissionStatus.real_time_limit_exceeded:
return {
line: null,
text: "程序跑了太久还没结束:可能有死循环,或者在等一个没有给的输入。",
raw: "",
}
case SubmissionStatus.memory_limit_exceeded:
return { line: null, text: "程序占的内存太多了。", raw: "" }
case SubmissionStatus.system_error:
return { line: null, text: "判题机出错了,不是代码的问题。", raw: err }
case SubmissionStatus.wrong_answer: {
const check = info?.sample_check
if (!check) return null
const text = check.passed
? "题目里的例子都对,错在没公开的测试点上。"
: `在例子 ${(check.index ?? 0) + 1} 上就错了。`
return { line: null, text, raw: "" }
}
case SubmissionStatus.ast_check_failed: {
const failedRules = (info?.ast_results ?? []).filter((rule) => !rule.passed)
const text = failedRules.length
? `答案对了,但没按要求写:${failedRules.map((rule) => rule.description).join(";")}`
: "答案对了,但没按题目要求的写法写。"
return { line: null, text, raw: "" }
}
default:
return null
}
})
const explainTone = computed(() => {
const r = props.row.result
return tone(
r === SubmissionStatus.compile_error ||
r === SubmissionStatus.runtime_error ||
r === SubmissionStatus.ast_check_failed
? "warning"
: r === SubmissionStatus.system_error
? "default"
: "error",
)
})
const rawOpen = ref(false)
watch(
() => props.row.id,
() => (rawOpen.value = false),
)
const failedCaseText = computed(() => {
const bad = cases.value
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.result !== SubmissionStatus.accepted)
if (!bad.length) return ""
const names = bad.map(({ index }) => index + 1).join("、")
const kinds = new Set(bad.map(({ item }) => JUDGE_STATUS[item.result]?.name ?? ""))
return kinds.size === 1 ? `测试点 ${names} ${[...kinds][0]}` : `测试点 ${names} 没通过`
})
function caseColor(result: number) {
if (result === SubmissionStatus.accepted) return tone("success").solid
if (result === SubmissionStatus.runtime_error) return tone("warning").solid
return tone("error").solid
}
const canRejudge = computed(() => props.teacher && !props.contest)
const moreOptions = computed(() => [
{ label: "复制回到题目", key: "problem" },
...(shown.value?.language !== "SQL" ? [{ label: "复制到自测猫", key: "cat" }] : []),
])
function handleMore(key: string) {
if (!shown.value) return
if (key === "problem") copyToProblem(shown.value)
else copyToCat(shown.value)
}
function openStandalone() {
window.open("/submission/" + props.row.id, "_blank")
}
</script>
<template>
<section class="pane" :class="{ narrow }">
<div class="head">
<div class="who">
<UserName :username="row.username" :muted="!!locked && locked !== 'problemset'" />
<button
v-if="teacher"
class="filter-pill"
title="只看这个人的提交"
@click="emit('filterUser', row.username)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他
</button>
</div>
<span class="dot">·</span>
<a class="problem" href="#" @click.prevent="emit('openProblem', row)">
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
v-if="teacher"
class="filter-pill"
title="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
</button>
<a
v-if="row.problemSet"
class="problemset"
:href="'/problemset/' + row.problemSet.id"
target="_blank"
:title="'从题单「' + row.problemSet.title + '」交的'"
>
题单 {{ row.problemSet.title }}
</a>
<div class="spacer"></div>
<template v-if="shown">
<template v-if="teacher">
<n-button v-if="canRejudge" size="small" @click="emit('rejudge', row.id)">
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新判题
</n-button>
<n-button
size="small"
quaternary
title="新页面打开(有测试点明细)"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
<n-dropdown :options="moreOptions" trigger="click" @select="handleMore">
<n-button size="small" quaternary aria-label="更多" title="复制回到题目、复制到自测猫">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
<template v-else>
<n-button size="small" type="primary" @click="copyToProblem(shown)">
<template #icon><Icon icon="ph:arrow-u-up-left" /></template>
复制回到题目
</n-button>
<n-button v-if="shown.language !== 'SQL'" size="small" @click="copyToCat(shown)">
复制到自测猫
</n-button>
<n-button
size="small"
quaternary
title="新页面打开"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
</template>
</template>
</div>
<div class="meta">
<StatusPill :result="row.result" />
<span v-if="teacher && cases.length" class="cases">
<span
v-for="(item, index) in cases"
:key="index"
class="case"
:style="{ background: caseColor(item.result), color: isDark ? '#18181c' : '#ffffff' }"
:title="`测试点 ${index + 1}:${JUDGE_STATUS[item.result]?.name ?? ''}`"
>
{{ index + 1 }}
</span>
<span v-if="failedCaseText" class="muted">{{ failedCaseText }}</span>
</span>
<span
v-else-if="row.caseSummary && row.caseSummary.passed < row.caseSummary.total"
class="partial"
:style="{ color: tone('error').color }"
>
通过 {{ row.caseSummary.passed }}/{{ row.caseSummary.total }} 个测试点
</span>
<span>{{ LANGUAGE_SHOW_VALUE[row.language] }}</span>
<span>{{ submissionTimeText(row.createTime, true) }}</span>
<template v-if="row.statisticInfo?.time_cost !== undefined">
<span>耗时 {{ submissionTimeFormat(row.statisticInfo.time_cost) }}</span>
<span>内存 {{ submissionMemoryFormat(row.statisticInfo.memory_cost) }}</span>
</template>
</div>
<!-- 看不到代码的三种情况 -->
<div v-if="locked" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<template v-if="locked === 'others'">
<span class="locked-title">别人的代码看不到</span>
<span class="muted">自己动手做出来,才是自己的。</span>
<n-button type="primary" @click="emit('openProblem', row)">
去做 {{ row.problemDisplayId }} {{ row.problemTitle }}
</n-button>
</template>
<template v-else-if="locked === 'problemset'">
<span class="locked-title">这道题在你加入的题单里</span>
<span class="muted">加入题单之前交的代码先藏起来,在题单里做完这道题就能看到。</span>
</template>
<template v-else>
<span class="locked-title">登录之后才能看自己的代码</span>
<span class="muted">谁交了什么题、对没对,不登录也能看。</span>
</template>
</div>
<template v-else>
<div
v-if="explain"
class="explain"
:style="{ background: explainTone.background, borderColor: explainTone.background }"
>
<div class="explain-line">
<b :style="{ color: explainTone.color }">
<template v-if="explain.line">第 {{ explain.line }} 行:</template>{{ explain.text }}
</b>
<div class="spacer"></div>
<a v-if="explain.raw" href="#" class="raw-toggle" @click.prevent="rawOpen = !rawOpen">
原始报错
<Icon :icon="rawOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" :width="11" />
</a>
</div>
<pre v-if="rawOpen && explain.raw" class="raw">{{ explain.raw }}</pre>
</div>
<CodeView
v-if="shown"
:code="shown.code"
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
:mark-line="explain?.line ?? null"
/>
<div v-else class="placeholder">
<n-spin v-if="loading" size="small" />
<span v-else-if="failed" class="muted">
代码没拉下来,
<a href="#" @click.prevent="load">再试一次</a>
</span>
</div>
</template>
<div v-if="!narrow" class="foot">
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
<span>
{{
teacher
? "换上一条 / 下一条,到本页头尾自动翻页"
: "换上一条 / 下一条(只在能看代码的提交之间走)"
}}
</span>
<div class="spacer"></div>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
</div>
</section>
</template>
<style scoped>
.pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: v-bind("theme.bodyColor");
}
.head {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: 15px;
min-width: 0;
}
.narrow {
padding: 12px 14px;
}
.narrow .head {
flex-wrap: wrap;
row-gap: 8px;
}
.narrow .head .spacer {
flex-basis: 100%;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dot {
color: v-bind("theme.textColor3");
}
.problem {
color: v-bind("theme.textColor1");
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.problem:hover {
color: v-bind("theme.primaryColor");
}
.problem-id {
color: v-bind("theme.textColor3");
}
.problemset {
flex: none;
height: 20px;
padding: 0 7px;
border-radius: 10px;
font-size: 12px;
display: inline-flex;
align-items: center;
text-decoration: none;
color: v-bind("tone('info').color");
background: v-bind("tone('info').background");
}
.filter-pill {
flex: none;
height: 22px;
padding: 0 7px;
border: 1px solid v-bind("tone('success').background");
border-radius: 11px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("tone('success').color");
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.filter-pill:hover {
background: v-bind("tone('success').background");
}
.spacer {
flex: 1 1 0;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: v-bind("theme.textColor3");
white-space: nowrap;
flex-wrap: wrap;
row-gap: 6px;
}
.cases {
display: inline-flex;
align-items: center;
gap: 4px;
}
.case {
width: 22px;
height: 22px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cases .muted {
margin-left: 4px;
}
.partial {
font-weight: 600;
}
.muted {
color: v-bind("theme.textColor3");
}
.explain {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 8px;
}
.explain-line {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.6;
}
.raw-toggle {
flex: none;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 3px;
color: v-bind("theme.textColor3");
text-decoration: none;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.locked,
.placeholder {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
padding: 16px;
border-radius: 6px;
}
.locked {
border: 1px dashed v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
}
.lock-icon {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.locked-title {
font-size: 16px;
font-weight: 600;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.keys {
display: inline-flex;
gap: 3px;
}
kbd {
min-width: 18px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
border: 1px solid v-bind("theme.borderColor");
border-radius: 3px;
background: v-bind("theme.cardColor");
font: inherit;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { USERNAME_CLASS_RE } from "utils/constants"
/**
* 用户名:班级前缀(`ks253`)淡一档,名字加粗 —— 一个班的人前缀都一样,
* 老师要认的是后面那段。`muted` 用在学生看别人的那几行(看得到结果、看不到代码)。
*/
const props = defineProps<{ username: string; muted?: boolean }>()
const theme = useThemeVars()
const parts = computed(() => {
const match = props.username.match(USERNAME_CLASS_RE)
if (!match) return { prefix: "", name: props.username }
return { prefix: match[0], name: props.username.slice(match[0].length) || match[0] }
})
</script>
<template>
<span class="user" :class="{ muted }" :title="username">
<span v-if="parts.prefix" class="prefix">{{ parts.prefix }}</span>
<span class="name">{{ parts.name }}</span>
</span>
</template>
<style scoped>
.user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.prefix {
color: v-bind("theme.textColor3");
}
.name {
font-weight: 600;
color: v-bind("theme.textColor1");
}
.muted .name {
font-weight: 400;
color: v-bind("theme.textColor2");
}
</style>
@@ -0,0 +1,60 @@
import { LANGUAGE_FORMAT_VALUE } from "utils/constants"
import { compressToBase64 } from "utils/functions"
import type { Submission } from "utils/types"
import { useCodeStore } from "oj/store/code"
import storage from "utils/storage"
/**
* 「复制到自测猫」「复制回到题目」。提交列表的右栏和独立的 /submission/:id 页面共用。
*/
export function useCopySubmission() {
const route = useRoute()
const router = useRouter()
const codeStore = useCodeStore()
function copyToCat(submission: Submission) {
const data = {
lang: LANGUAGE_FORMAT_VALUE[submission.language],
code: submission.code,
input: "",
}
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
/**
* 编辑器的 storageKey 用 display id(problem._id),不是 submission.problemId
* (内部数字 id)。**不能只靠调用方传的题号** —— 独立的 /submission/:id 路由
* 只有提交 id,原来会一路带进 router.push 抛 `Missing required param "problemID"`。
* 响应里的 problemDisplayId 就是干这个的。
*
* 比赛提交的 contestId 只下发给管理员;学生在比赛里复制,靠的是当前路由上的比赛 id。
*/
function copyToProblem(submission: Submission) {
const { code, language } = submission
const problemID = submission.problemDisplayId
const contestId =
submission.contestId ?? (route.params.contestID ? Number(route.params.contestID) : null)
const storageKey = `problem_${problemID}_contest_${contestId || null}_lang_${language}`
storage.set(storageKey, code)
// 设置语言 + 代码:localStorage 覆盖全新挂载的编辑器,
// setCode 覆盖已挂载(同页 modal)的编辑器
codeStore.setLanguage(language)
codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? ""
if (contestId) {
router.push({
name: "contest problem",
params: { contestID: String(contestId), problemID },
})
} else if (problemSetId) {
router.push({ name: "problemset problem", params: { problemSetId, problemID } })
} else {
router.push({ name: "problem", params: { problemID } })
}
}
return { copyToCat, copyToProblem }
}
@@ -0,0 +1,50 @@
import { useThemeVars } from "naive-ui"
import { useDark } from "@vueuse/core"
export type Tone = "success" | "error" | "warning" | "info" | "default"
function rgba(hex: string, alpha: number) {
const value = hex.replace("#", "")
const full =
value.length === 3
? value
.split("")
.map((c) => c + c)
.join("")
: value.slice(0, 6)
const n = parseInt(full, 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
/**
* 提交列表里各种小胶囊的配色:浅底 + 深一档的字(设计稿「提交列表重设计 · 定稿」)。
* 从主题色里取,暗色下字换成主题色本身(深色字放在暗底上看不清)。
* 不用 color-mix:Chrome 105 没有(111 才有),机房有一部分还是 105。
*/
export function useTone() {
const theme = useThemeVars()
const isDark = useDark()
return function tone(kind: Tone) {
const t = theme.value
const base = {
success: t.successColor,
error: t.errorColor,
warning: t.warningColor,
info: t.infoColor,
default: t.textColor3,
}[kind]
const pressed = {
success: t.successColorPressed,
error: t.errorColorPressed,
warning: t.warningColorPressed,
info: t.infoColorPressed,
default: t.textColor2,
}[kind]
return {
color: isDark.value ? base : pressed,
background: kind === "default" ? t.actionColor : rgba(base, isDark.value ? 0.16 : 0.12),
solid: base,
}
}
}
+5 -48
View File
@@ -8,13 +8,11 @@ import {
submissionMemoryFormat, submissionMemoryFormat,
submissionResultTitle, submissionResultTitle,
submissionTimeFormat, submissionTimeFormat,
compressToBase64,
} from "utils/functions" } from "utils/functions"
import type { Submission } from "utils/types" import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useCodeStore } from "oj/store/code" import { useCopySubmission } from "./composables/copySubmission"
import storage from "utils/storage"
const props = defineProps<{ const props = defineProps<{
submissionID: string submissionID: string
@@ -26,10 +24,6 @@ const props = defineProps<{
// 在弹框中使用时,父组件监听此事件关闭弹框,否则弹框会挡住已更新的编辑器 // 在弹框中使用时,父组件监听此事件关闭弹框,否则弹框会挡住已更新的编辑器
const emit = defineEmits<{ copied: [] }>() const emit = defineEmits<{ copied: [] }>()
const route = useRoute()
const router = useRouter()
const codeStore = useCodeStore()
const { isMobile, isDesktop } = useBreakpoints() const { isMobile, isDesktop } = useBreakpoints()
const submission = ref<Submission>() const submission = ref<Submission>()
@@ -69,51 +63,14 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
}, },
] ]
const { copyToCat: catCopy, copyToProblem: problemCopy } = useCopySubmission()
function copyToCat() { function copyToCat() {
const lang = LANGUAGE_FORMAT_VALUE[submission.value!.language] catCopy(submission.value!)
const data = {
lang,
code: submission.value!.code,
input: "",
}
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
} }
function copyToProblem() { function copyToProblem() {
const { code, language, contestId } = submission.value! problemCopy(submission.value!)
// 编辑器的 storageKey 用 display id(problem._id),不是 submission.problemId
// (内部数字 id)。**不能只靠 props.problemID** —— 独立的 /submission/:id 路由
// 只喂 submissionID,那个 prop 是 undefined,原来会一路带进 router.push 抛
// `Missing required param "problemID"`。响应里的 problemDisplayId 就是干这个的。
const problemID = props.problemID ?? submission.value!.problemDisplayId
const contestIDForKey = contestId || null
const storageKey = `problem_${problemID}_contest_${contestIDForKey}_lang_${language}`
storage.set(storageKey, code)
// 设置语言 + 代码:localStorage 覆盖全新挂载的编辑器,
// setCode 覆盖已挂载(同页 modal)的编辑器
codeStore.setLanguage(language)
codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? ""
if (contestId) {
router.push({
name: "contest problem",
params: { contestID: String(contestId), problemID },
})
} else if (problemSetId) {
router.push({
name: "problemset problem",
params: { problemSetId, problemID },
})
} else {
router.push({
name: "problem",
params: { problemID },
})
}
emit("copied") emit("copied")
} }
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
import { parseTime } from "utils/functions"
/**
* 提交时间的显示:今天的只写钟点,别的日子带上月日,跨年再带年份。列表里一眼看的是
* 「刚才」还是「以前」,完整的年月日时分秒只会把这一列撑宽。
* 日历按东八区算(parseTime),不跟浏览器时区走。
*/
export function submissionTimeText(time: string, withSeconds = false) {
const clock = parseTime(time, withSeconds ? "HH:mm:ss" : "HH:mm")
const day = parseTime(time, "YYYY-MM-DD")
const now = parseTime(new Date(), "YYYY-MM-DD")
if (day === now) return clock
if (day.slice(0, 4) === now.slice(0, 4)) return `${parseTime(time, "M月D日")} ${clock}`
return `${parseTime(time, "YYYY年M月D日")} ${clock}`
}
/** 列表里每行只写钟点,日期在分隔行上(submissionDayText) */
export function submissionClockText(time: string) {
return parseTime(time, "HH:mm")
}
/** 分隔行上的日子:今天 / 昨天 / 9月28日(跨年带年份) */
export function submissionDayText(time: string) {
const day = parseTime(time, "YYYY-MM-DD")
const now = parseTime(new Date(), "YYYY-MM-DD")
const yesterday = parseTime(new Date(Date.now() - 86_400_000), "YYYY-MM-DD")
if (day === now) return "今天"
if (day === yesterday) return `昨天 · ${parseTime(time, "M月D日")}`
if (day.slice(0, 4) === now.slice(0, 4)) return parseTime(time, "M月D日")
return parseTime(time, "YYYY年M月D日")
}
/** 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法 */
export function classLabel(className: string) {
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
@@ -13,7 +13,8 @@
clearable clearable
/> />
<n-select style="width: 120px" v-model:value="query.duration" :options="durationOptions" /> <n-select style="width: 120px" v-model:value="query.duration" :options="durationOptions" />
<n-button type="primary" @click="handleStatistics">统计</n-button> <n-button type="primary" :loading="loading" @click="handleStatistics">统计</n-button>
<n-text v-if="failed" type="error">统计没拉下来,稍后会自动再试</n-text>
</n-flex> </n-flex>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" /> <n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
@@ -42,8 +43,9 @@
</div> </div>
<div class="stat-item"> <div class="stat-item">
<n-text>班级人数</n-text> <n-text>班级人数</n-text>
<!-- 减掉请假隐藏的,和下面完成度的分母、代码统计面板是同一个数 -->
<n-gradient-text type="warning" font-size="28"> <n-gradient-text type="warning" font-size="28">
{{ data.personCount }} {{ adjustedPersonCount }}
</n-gradient-text> </n-gradient-text>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@@ -102,8 +104,10 @@
</n-grid> </n-grid>
</n-tab-pane> </n-tab-pane>
<!-- 有花名册就一直在(和代码统计面板一样):原来人一做完这个页签就整个消失,
老师找不到「全都完成了」那句 -->
<n-tab-pane <n-tab-pane
v-if="data.dataUnaccepted.length > 0" v-if="data.personCount > 0 || data.dataUnaccepted.length > 0"
name="unaccepted" name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length})`" :tab="`未完成(${visibleUnaccepted.length})`"
> >
@@ -117,7 +121,10 @@
</n-button> </n-button>
</n-flex> </n-flex>
<n-flex size="large" align="center"> <n-flex size="large" align="center">
<n-gradient-text v-if="visibleUnaccepted.length === 0" font-size="24" type="success"> <n-text v-if="visibleUnaccepted.length === 0 && hiddenCount > 0" depth="3">
没完成的 {{ hiddenCount }} 位都隐藏了
</n-text>
<n-gradient-text v-else-if="visibleUnaccepted.length === 0" font-size="24" type="success">
全都完成了 全都完成了
</n-gradient-text> </n-gradient-text>
<template v-for="item in visibleUnaccepted" :key="item.username"> <template v-for="item in visibleUnaccepted" :key="item.username">
@@ -186,8 +193,6 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const message = useMessage()
const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS] const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS]
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery // 班级、题号、时间段记一小时,理由见 useStatisticsQuery
@@ -437,7 +442,14 @@ const subOptions = computed<Duration>(
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!, () => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
) )
const loading = ref(false)
/** 上一次统计失败了。不弹 toast:会自动刷新,断网时会一直弹 */
const failed = ref(false)
async function handleStatistics() { async function handleStatistics() {
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
if (loading.value) return
loading.value = true
const current = Date.now() const current = Date.now()
const end = formatISO(current) const end = formatISO(current)
const duration = const duration =
@@ -446,15 +458,34 @@ async function handleStatistics() {
const res = await getFlowchartStatistics(duration, query.problem, query.username) const res = await getFlowchartStatistics(duration, query.problem, query.username)
Object.assign(data, res) Object.assign(data, res)
saveQuery() saveQuery()
failed.value = false
} catch (error) { } catch (error) {
message.error("获取流程图统计失败") failed.value = true
console.error("获取流程图统计失败:", error) console.error("获取流程图统计失败:", error)
return return
} finally {
loading.value = false
} }
await nextTick() await nextTick()
renderWordCloud() renderWordCloud()
} }
/**
* 和代码统计面板一样:打开就查、开着就刷(老师是投在屏幕上盯着看的)。
* 流程图统计要给词云分词,比代码统计重,所以 30 秒一次,而且只刷一天以内的时间段 ——
* 查一周、一学年的是回头看,不是盯课堂,不必反复重算
*/
onMounted(handleStatistics)
const visibility = useDocumentVisibility()
useIntervalFn(() => {
if (visibility.value !== "visible") return
if (query.duration === "all") return
const d = subOptions.value
if (d.weeks || d.months || d.years || (d.days ?? 0) > 1) return
handleStatistics()
}, 30_000)
onUnmounted(() => { onUnmounted(() => {
if (wordcloudChart) { if (wordcloudChart) {
wordcloudChart.destroy() wordcloudChart.destroy()
@@ -18,6 +18,7 @@
<n-select style="width: 120px" v-model:value="query.duration" :options="options" /> <n-select style="width: 120px" v-model:value="query.duration" :options="options" />
<n-button type="primary" :loading="loading" @click="handleStatistics"> 统计 </n-button> <n-button type="primary" :loading="loading" @click="handleStatistics"> 统计 </n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions"> 前往提交列表 </n-button> <n-button v-if="route.name !== 'submissions'" @click="goSubmissions"> 前往提交列表 </n-button>
<n-text v-if="failed" type="error">统计没拉下来,15 秒后会自动再试</n-text>
</n-flex> </n-flex>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" /> <n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
@@ -104,7 +105,11 @@
恢复 {{ hiddenCount }} 位 恢复 {{ hiddenCount }} 位
</n-button> </n-button>
</n-flex> </n-flex>
<n-gradient-text v-if="unfinishedGroups.length === 0" font-size="24" type="success"> <!-- 名单上的人全被「请假隐藏」了不等于全做完了,别让老师误以为全班过了 -->
<n-text v-if="unfinishedGroups.length === 0 && hiddenCount > 0" depth="3">
没完成的 {{ hiddenCount }} 位都隐藏了
</n-text>
<n-gradient-text v-else-if="unfinishedGroups.length === 0" font-size="24" type="success">
全都完成了 全都完成了
</n-gradient-text> </n-gradient-text>
<template v-for="group in unfinishedGroups" :key="group.title"> <template v-for="group in unfinishedGroups" :key="group.title">
@@ -647,9 +652,10 @@ const pieChartOptions = {
}, },
} }
// 环形图数据 - 班级完成度 // 环形图数据 - 班级完成度。「已完成」和上面数字行的「完成人数」是同一个数(做完的),
// 原来这里用的是 list.length —— 那是所有交过的人,交了没对的也被画成了已完成
const completionChartData = computed(() => { const completionChartData = computed(() => {
const completedCount = list.value.length const completedCount = doneList.value.length
const uncompletedCount = Math.max(0, adjustedPersonCount.value - completedCount) const uncompletedCount = Math.max(0, adjustedPersonCount.value - completedCount)
return { return {
labels: ["已完成", "未完成"], labels: ["已完成", "未完成"],
@@ -703,12 +709,21 @@ function goSubmissions() {
} }
const loading = ref(false) const loading = ref(false)
/**
* 上一次统计失败了。原来没 catch,请求一失败面板上什么都不说,老师看着的还是旧数字。
* 不弹 toast:每 15 秒自动刷新一次,断网时会一直弹
*/
const failed = ref(false)
async function handleStatistics() { async function handleStatistics() {
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次 // 自动刷新和手点可能撞上,上一次没回来就跳过这一次
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
await fetchStatistics() await fetchStatistics()
failed.value = false
} catch {
failed.value = true
} finally { } finally {
loading.value = false loading.value = false
} }
+3 -1
View File
@@ -268,6 +268,8 @@ export interface SubmissionListPayload {
username?: string username?: string
/** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */ /** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */
exactUsername?: "1" exactUsername?: "1"
/** 班级(user.class_name,如 `253`),精确匹配;和 username 可以同时用 */
className?: string
contestId?: string contestId?: string
problemDisplayId?: string problemDisplayId?: string
language: LANGUAGE | "" language: LANGUAGE | ""
@@ -293,7 +295,7 @@ export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contrac
export type { ClassActivity, ClassActivityProblem } from "@oj2/contract" export type { ClassActivity, ClassActivityProblem } from "@oj2/contract"
/** 课堂看板:一个班今天这节课的题 × 全班学生 */ /** 课堂看板:一个班今天这节课的题 × 全班学生 */
export type { ClassBoard, ClassBoardStudent, ClassBoardCell } from "@oj2/contract" export type { ClassBoard, ClassBoardStudent, ClassBoardCell, ClassLesson } from "@oj2/contract"
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */ /** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
export type { KnowledgeLevel, KnowledgeMap } from "@oj2/contract" export type { KnowledgeLevel, KnowledgeMap } from "@oj2/contract"
+12
View File
@@ -136,6 +136,17 @@ export const classBoardSchema = z.object({
recentLessons: z.number().int(), recentLessons: z.number().int(),
}) })
/**
* 提交列表「这节课」那颗按钮要的:哪个班、这节课是哪几道题。和课堂看板同一套取法
* (老师布置过就用布置的,否则按今天同班提交推断),但不带花名册 —— 列表只拿它当筛选条件。
* className 为 null = 没指定、最近两小时也没有哪个班在交。
*/
export const classLessonSchema = z.object({
className: z.string().nullable(),
source: z.enum(["teacher", "inferred"]).nullable(),
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
})
export type ClassRankItem = z.infer<typeof classRankItemSchema> export type ClassRankItem = z.infer<typeof classRankItemSchema>
export type ClassUserRank = z.infer<typeof classUserRankSchema> export type ClassUserRank = z.infer<typeof classUserRankSchema>
export type ClassComparison = z.infer<typeof classComparisonSchema> export type ClassComparison = z.infer<typeof classComparisonSchema>
@@ -144,6 +155,7 @@ export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSche
export type ClassActivity = z.infer<typeof classActivitySchema> export type ClassActivity = z.infer<typeof classActivitySchema>
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema> export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
export type ClassBoard = z.infer<typeof classBoardSchema> export type ClassBoard = z.infer<typeof classBoardSchema>
export type ClassLesson = z.infer<typeof classLessonSchema>
export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema> export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema> export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
export type ClassBoardCell = z.infer<typeof classBoardCellSchema> export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
+22 -14
View File
@@ -189,6 +189,21 @@ export const createSubmissionResponseSchema = z.object({
submissionId: z.string(), submissionId: z.string(),
}) })
/**
* 通过了几个测试点,给学生看「离 AC 还差多远」。`info` 只给管理员(每个点带
* output_md5),所以这里只算出两个数下发,不放开原文。
*
* 为 null 的情形:比赛提交(ACM 只报对错,多给通过数等于变相放水,和 AI 提示同口径)、
* SQL 题(`judge/run.ts` 遇到被杀的测试点会 break,total 偏小)、没有逐点结果
* (待判、编译失败)。
*/
export const caseSummarySchema = z
.object({
passed: z.number().int(),
total: z.number().int(),
})
.nullable()
export const submissionDetailSchema = z.object({ export const submissionDetailSchema = z.object({
id: z.string(), id: z.string(),
createTime: z.string(), createTime: z.string(),
@@ -210,20 +225,8 @@ export const submissionDetailSchema = z.object({
*/ */
problemDisplayId: z.string(), problemDisplayId: z.string(),
showLink: z.boolean(), showLink: z.boolean(),
/** /** 通过了几个测试点,口径见 caseSummarySchema */
* 通过了几个测试点,给学生看「离 AC 还差多远」。`info` 只给管理员(每个点带 caseSummary: caseSummarySchema,
* output_md5),所以这里只算出两个数下发,不放开原文。
*
* 为 null 的情形:比赛提交(ACM 只报对错,多给通过数等于变相放水,和 AI 提示同口径)、
* SQL 题(`judge/run.ts` 遇到被杀的测试点会 break,total 偏小)、没有逐点结果
* (待判、编译失败)。
*/
caseSummary: z
.object({
passed: z.number().int(),
total: z.number().int(),
})
.nullable(),
}) })
/** /**
@@ -277,6 +280,11 @@ export const submissionListItemSchema = z.object({
* 回填的就是这些,其余老提交无从判断入口,一律留空。 * 回填的就是这些,其余老提交无从判断入口,一律留空。
*/ */
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(), problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
/**
* 通过了几个测试点,和详情同一个口径(见 caseSummarySchema)。列表上老师一眼要分出
* 「差一个点」和「一个都没过」,不必逐条点开。
*/
caseSummary: caseSummarySchema,
}) })
export const submissionListSchema = paginatedSchema(submissionListItemSchema) export const submissionListSchema = paginatedSchema(submissionListItemSchema)