Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe3caa944 | ||
|
|
948e9a734d | ||
|
|
6a0f6399b7 | ||
|
|
5bb69ecce3 | ||
|
|
c64b0a402b | ||
|
|
116b0a2c95 |
No files matched your search
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 流程图的等级。一律由分数推出来,不采信模型自报的 grade:提示词里写死了这四档,
|
||||
* 但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果,甚至直接吐「优秀」;脏值会一路
|
||||
* 串到等级分布图和「A/S 才算过关」的判断里。
|
||||
*/
|
||||
export function gradeForScore(score: number) {
|
||||
if (score >= 90) return "S"
|
||||
if (score >= 80) return "A"
|
||||
if (score >= 70) return "B"
|
||||
return "C"
|
||||
}
|
||||
|
||||
/**
|
||||
* 画到这两档算这道题做完:题单进度、课堂条、「你画的流程图」都按它
|
||||
* (设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)
|
||||
*/
|
||||
export const FLOWCHART_PASS_GRADES = ["S", "A"]
|
||||
|
||||
export function isFlowchartPass(grade: string | null | undefined) {
|
||||
return grade === "S" || grade === "A"
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { publishFlowchartUpdate } from "../events"
|
||||
import { completeChat } from "../services/ai"
|
||||
import { recordSolvedAndNotify } from "../services/problemset"
|
||||
import { gradeForScore, isFlowchartPass } from "./grade"
|
||||
import type { FlowchartJobData } from "./job"
|
||||
|
||||
function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
||||
@@ -16,18 +18,6 @@ function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
||||
题目:${problem.title}\n${problem.description.slice(0, 2000)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 等级一律由分数推出来,不采信模型自报的 grade。
|
||||
* 提示词里写死了这四档,但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果,
|
||||
* 甚至直接吐「优秀」;脏值会一路串到等级分布图和「A/S 才展示流程图」的判断里。
|
||||
*/
|
||||
function gradeForScore(score: number) {
|
||||
if (score >= 90) return "S"
|
||||
if (score >= 80) return "A"
|
||||
if (score >= 70) return "B"
|
||||
return "C"
|
||||
}
|
||||
|
||||
function parseEvaluation(value: string) {
|
||||
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
|
||||
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
||||
@@ -89,6 +79,17 @@ export async function evaluateFlowchart(
|
||||
evaluationTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路
|
||||
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
|
||||
// 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单
|
||||
if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
|
||||
await recordSolvedAndNotify(
|
||||
row.flowchart.userId,
|
||||
row.problem.id,
|
||||
null,
|
||||
new Date().toISOString(),
|
||||
)
|
||||
}
|
||||
await publishFlowchartUpdate(row.flowchart.userId, {
|
||||
type: "flowchart_evaluation_completed",
|
||||
submissionId: row.flowchart.id,
|
||||
|
||||
@@ -6,11 +6,8 @@ import { and, eq, inArray } from "drizzle-orm"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { publishAchievementNotification } from "../events"
|
||||
import {
|
||||
updateAchievementsForProblemSet,
|
||||
updateAchievementsForSubmission,
|
||||
} from "../services/achievements"
|
||||
import { recordSolvedProblem } from "../services/problemset"
|
||||
import { updateAchievementsForSubmission } from "../services/achievements"
|
||||
import { recordSolvedAndNotify } from "../services/problemset"
|
||||
import { checkAst, type AstRule } from "./ast"
|
||||
import { publishSubmissionUpdate } from "./events"
|
||||
import { asRecord } from "../routes/helpers"
|
||||
@@ -580,43 +577,12 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
// 进度已经落库,跳回题单页看到的就是新数据。
|
||||
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
|
||||
if (row.submission.contestId === null && isAccepted(result)) {
|
||||
try {
|
||||
const { updated, earned } = await recordSolvedProblem(
|
||||
await recordSolvedAndNotify(
|
||||
row.submission.userId,
|
||||
row.problem.id,
|
||||
row.submission.id,
|
||||
row.submission.createTime,
|
||||
)
|
||||
if (earned.length > 0) {
|
||||
await publishAchievementNotification(
|
||||
row.submission.userId,
|
||||
earned.map((badge) => ({
|
||||
id: badge.id,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
rarity: "bronze",
|
||||
kind: "badge",
|
||||
})),
|
||||
)
|
||||
}
|
||||
if (updated > 0) {
|
||||
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
|
||||
await publishAchievementNotification(
|
||||
row.submission.userId,
|
||||
unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to record problem set progress for ${row.submission.id}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Hono } from "hono"
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { FLOWCHART_PASS_GRADES } from "../flowchart/grade"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { calendarDay, dayStart, localTime } from "../time"
|
||||
import { queryInteger, rounded } from "./helpers"
|
||||
@@ -326,6 +327,29 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
|
||||
: []
|
||||
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
|
||||
|
||||
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
|
||||
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」
|
||||
const drawn = ids.length
|
||||
? await db
|
||||
.select({
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES)})`,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
inArray(schema.flowchartSubmission.problemId, ids),
|
||||
// 只数评完了的:评分中、评失败的那次不算「做过」
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.flowchartSubmission.problemId)
|
||||
: []
|
||||
for (const row of drawn) {
|
||||
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
|
||||
}
|
||||
|
||||
return success(c, {
|
||||
className: user.className,
|
||||
day: lesson.day,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartCurrent,
|
||||
type FlowchartDetail,
|
||||
type FlowchartScores,
|
||||
type FlowchartList,
|
||||
type FlowchartListItem,
|
||||
type FlowchartStatistics,
|
||||
@@ -14,11 +15,13 @@ import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizz
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, readJson, success } from "../http"
|
||||
import { flowchartQueue } from "../queue"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { problemSetJoinTimes } from "../services/problemset"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
import { buildWordFrequencies } from "../services/word-frequency"
|
||||
import { dayStart } from "../time"
|
||||
@@ -38,14 +41,38 @@ function flowchartThrottleKey(userId: number) {
|
||||
return `flowchart:${userId}`
|
||||
}
|
||||
|
||||
function canView(
|
||||
user: import("../auth/session").AuthUser,
|
||||
row: { userId: number },
|
||||
problem: { createdById: number },
|
||||
/**
|
||||
* 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸
|
||||
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图
|
||||
* 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
||||
*/
|
||||
function hiddenByProblemSet(
|
||||
user: AuthUser,
|
||||
row: { userId: number; problemId: number; createTime: string },
|
||||
joinTimes: Map<number, string>,
|
||||
) {
|
||||
if (row.userId !== user.id || isAdminRole(user)) return false
|
||||
const joinTime = joinTimes.get(row.problemId)
|
||||
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
|
||||
}
|
||||
|
||||
function canView(
|
||||
user: AuthUser,
|
||||
row: { userId: number; problemId: number; createTime: string },
|
||||
problem: { createdById: number },
|
||||
joinTimes: Map<number, string>,
|
||||
) {
|
||||
if (hiddenByProblemSet(user, row, joinTimes)) return false
|
||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
||||
}
|
||||
|
||||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
||||
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||||
if (isAdminRole(user)) return Promise.resolve(new Map<number, string>())
|
||||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||||
return problemSetJoinTimes(user.id, [...new Set(own)])
|
||||
}
|
||||
|
||||
function flowchartData(
|
||||
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
||||
username: string,
|
||||
@@ -188,8 +215,9 @@ async function flowchartUserFilter(username: string) {
|
||||
const flowchartListColumns = {
|
||||
flowchart: {
|
||||
id: schema.flowchartSubmission.id,
|
||||
// showLink 判定要,序列化本身用不到
|
||||
// showLink 判定要(本人 + 题单闸门),序列化本身用不到
|
||||
userId: schema.flowchartSubmission.userId,
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
status: schema.flowchartSubmission.status,
|
||||
createTime: schema.flowchartSubmission.createTime,
|
||||
aiScore: schema.flowchartSubmission.aiScore,
|
||||
@@ -250,6 +278,10 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
const joinTimes = await joinTimesFor(
|
||||
user,
|
||||
rows.map((row) => row.flowchart),
|
||||
)
|
||||
return success(c, {
|
||||
results: rows.map(
|
||||
({ flowchart, username, problem }) =>
|
||||
@@ -266,7 +298,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
showLink: canView(user, flowchart, problem, joinTimes),
|
||||
}) satisfies FlowchartListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
@@ -514,7 +546,8 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||
.limit(1)
|
||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem))
|
||||
const user = c.get("user")!
|
||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
return success(c, flowchartData(row.flowchart, row.username))
|
||||
})
|
||||
@@ -527,7 +560,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||
.limit(1)
|
||||
if (!row || !canView(user, row.flowchart, row.problem))
|
||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
if (![2, 3].includes(row.flowchart.status))
|
||||
return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
||||
@@ -582,33 +615,13 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
} satisfies CreateFlowchartResponse)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const rows = await db
|
||||
.select({
|
||||
score: schema.flowchartSubmission.aiScore,
|
||||
grade: schema.flowchartSubmission.aiGrade,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
|
||||
eq(schema.flowchartSubmission.problemId, problemId),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
return success(c, {
|
||||
count: rows.length,
|
||||
score: rows[0]?.score ?? 0,
|
||||
grade: rows[0]?.grade ?? "",
|
||||
} satisfies FlowchartCurrent)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const rows = await db
|
||||
/**
|
||||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
|
||||
* current / history / scores 三个接口都从这一份出。
|
||||
*/
|
||||
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
const [rows, joinTimes] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
flowchart: schema.flowchartSubmission,
|
||||
username: schema.user.username,
|
||||
@@ -617,12 +630,33 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
eq(schema.flowchartSubmission.problemId, problemId),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime))
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
||||
joinTimesFor(user, [{ userId: user.id, problemId }]),
|
||||
])
|
||||
const visible = rows.filter((row) => !hiddenByProblemSet(user, row.flowchart, joinTimes))
|
||||
return { visible, hidden: rows.length - visible.length }
|
||||
}
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
const latest = visible.at(-1)?.flowchart
|
||||
return success(c, {
|
||||
count: visible.length,
|
||||
score: latest?.aiScore ?? 0,
|
||||
grade: latest?.aiGrade ?? "",
|
||||
} satisfies FlowchartCurrent)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const { visible: rows } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
return success(c, {
|
||||
@@ -630,3 +664,21 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
|
||||
count: rows.length,
|
||||
} satisfies FlowchartDetail)
|
||||
})
|
||||
|
||||
/**
|
||||
* 历次分数:结果页签上那一排「第 N 次 xx 分」。原来 history 一次只给一条,
|
||||
* 要看分数走势得一页一页翻。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。
|
||||
*/
|
||||
flowchartRoutes.get("/problems/:id/flowchart/scores", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const { visible, hidden } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
return success(c, {
|
||||
scores: visible.map(({ flowchart }) => ({
|
||||
id: flowchart.id,
|
||||
score: flowchart.aiScore ?? 0,
|
||||
grade: flowchart.aiGrade ?? "",
|
||||
createTime: flowchart.createTime,
|
||||
})),
|
||||
hidden,
|
||||
} satisfies FlowchartScores)
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SubmissionListItem,
|
||||
type SubmissionTrace,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, gt, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||
import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware"
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "../services/contest"
|
||||
import { CodeFormatError, formatCode } from "../services/format-code"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { problemSetJoinTimes } from "../services/problemset"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
import { dayStart } from "../time"
|
||||
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
|
||||
@@ -248,53 +249,6 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
|
||||
*
|
||||
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
|
||||
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
|
||||
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
|
||||
*
|
||||
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
|
||||
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
|
||||
* - 题单被归档(status 不是 active)
|
||||
*
|
||||
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
|
||||
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
|
||||
* 行为不确定,换成聚合顺手定死。
|
||||
*/
|
||||
async function problemSetJoinTimes(userId: number, problemIds: number[]) {
|
||||
const joinTimes = new Map<number, string>()
|
||||
if (problemIds.length === 0) return joinTimes
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
|
||||
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
|
||||
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
|
||||
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
||||
})
|
||||
.from(schema.problemsetProgress)
|
||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
|
||||
.innerJoin(
|
||||
schema.problemsetProblem,
|
||||
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
inArray(schema.problemsetProblem.problemId, problemIds),
|
||||
eq(schema.problemset.status, "active"),
|
||||
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
|
||||
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.problemsetProblem.problemId)
|
||||
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
|
||||
return joinTimes
|
||||
}
|
||||
|
||||
// 参数按「实际用到的字段」声明,而不是整行 $inferSelect:列表接口只 select 需要的列,
|
||||
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
|
||||
function canViewSubmission(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { and, eq, notInArray, sql } from "drizzle-orm"
|
||||
import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { publishAchievementNotification } from "../events"
|
||||
import { asRecord } from "../routes/helpers"
|
||||
import { updateAchievementsForProblemSet } from "./achievements"
|
||||
|
||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
||||
@@ -238,7 +240,12 @@ export async function resyncProgress(problemsetId: number) {
|
||||
export async function recordSolvedProblem(
|
||||
userId: number,
|
||||
problemId: number,
|
||||
submissionId: string,
|
||||
/**
|
||||
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
||||
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
|
||||
* 那道闸看的是 progress_detail(routes/submission.ts 的 problemSetJoinTimes)
|
||||
*/
|
||||
submissionId: string | null,
|
||||
solvedAt: string,
|
||||
) {
|
||||
const joined = await db
|
||||
@@ -281,7 +288,7 @@ export async function recordSolvedProblem(
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!existing) {
|
||||
if (!existing && submissionId !== null) {
|
||||
await tx
|
||||
.insert(schema.problemsetSubmission)
|
||||
.values({ problemsetId, userId, submissionId, problemId })
|
||||
@@ -334,3 +341,99 @@ export async function recordSolvedProblem(
|
||||
}
|
||||
return { updated, earned }
|
||||
}
|
||||
|
||||
/**
|
||||
* 记题单进度,并把这一下拿到的徽章、解锁的题单类成就推给学生。判题(judge/run.ts)和
|
||||
* 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
|
||||
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
|
||||
*/
|
||||
export async function recordSolvedAndNotify(
|
||||
userId: number,
|
||||
problemId: number,
|
||||
submissionId: string | null,
|
||||
solvedAt: string,
|
||||
) {
|
||||
try {
|
||||
const { updated, earned } = await recordSolvedProblem(userId, problemId, submissionId, solvedAt)
|
||||
if (earned.length > 0) {
|
||||
await publishAchievementNotification(
|
||||
userId,
|
||||
earned.map((badge) => ({
|
||||
id: badge.id,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
rarity: "bronze",
|
||||
kind: "badge",
|
||||
})),
|
||||
)
|
||||
}
|
||||
if (updated > 0) {
|
||||
const unlocked = await updateAchievementsForProblemSet(userId)
|
||||
await publishAchievementNotification(
|
||||
userId,
|
||||
unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to record problem set progress for user ${userId} problem ${problemId}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
|
||||
* 代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)共用这一道闸。
|
||||
*
|
||||
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
|
||||
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
|
||||
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
|
||||
*
|
||||
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
|
||||
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
|
||||
* - 题单被归档(status 不是 active)
|
||||
*
|
||||
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
|
||||
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
|
||||
* 行为不确定,换成聚合顺手定死。
|
||||
*/
|
||||
export async function problemSetJoinTimes(userId: number, problemIds: number[]) {
|
||||
const joinTimes = new Map<number, string>()
|
||||
if (problemIds.length === 0) return joinTimes
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
|
||||
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
|
||||
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
|
||||
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
||||
})
|
||||
.from(schema.problemsetProgress)
|
||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
|
||||
.innerJoin(
|
||||
schema.problemsetProblem,
|
||||
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
inArray(schema.problemsetProblem.problemId, problemIds),
|
||||
eq(schema.problemset.status, "active"),
|
||||
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
|
||||
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.problemsetProblem.problemId)
|
||||
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
|
||||
return joinTimes
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia"
|
||||
import type { RouteLocationRaw } from "vue-router"
|
||||
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import type { ProblemSet, ProblemSetProblem } from "utils/types"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import ProblemChips, { type ChipItem } from "./ProblemChips.vue"
|
||||
|
||||
/**
|
||||
* 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条:
|
||||
*
|
||||
* - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题
|
||||
* - 题单:题单名 + 进度 + 第几题 + 上一题 / 下一题
|
||||
* - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名
|
||||
* - 都不是:不显示
|
||||
*
|
||||
* 一道题既在这节课里、又是从题单进来的,按入口显示题单条。
|
||||
* 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。
|
||||
*/
|
||||
|
||||
/** 题多于这个数就横向滚动、去掉 ‹ ›:一节课一般 5 道以内,按 5 道设计 */
|
||||
const CHIPS_FIT = 5
|
||||
|
||||
const ctx = useProblemPageContext()
|
||||
const userStore = useUserStore()
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const currentId = computed(() => problem.value?._id ?? "")
|
||||
|
||||
/** 刚在这一页上做出来(不是本来就做过):换题时 id 跟着变,那一次不算 */
|
||||
function onSolvedHere(callback: () => void) {
|
||||
watch(
|
||||
() => [problem.value?.id, problem.value?.myStatus] as const,
|
||||
([id, status], [previousId, previousStatus]) => {
|
||||
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 课堂 ====================
|
||||
const lessonStore = useLessonStore()
|
||||
|
||||
watch(
|
||||
() => ctx.value.entry === "problem" && userStore.isAuthed,
|
||||
(want) => {
|
||||
// 接口要求登录;没登录就没有这一条
|
||||
if (want) lessonStore.load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const lesson = computed(() => {
|
||||
if (ctx.value.entry !== "problem" || !userStore.isAuthed) return null
|
||||
const list = lessonStore.activity?.problems ?? []
|
||||
const index = list.findIndex(
|
||||
(item) => item.problemDisplayId.toLowerCase() === currentId.value.toLowerCase(),
|
||||
)
|
||||
if (index < 0) return null
|
||||
const chips: ChipItem[] = list.map((item) => ({
|
||||
displayId: item.problemDisplayId,
|
||||
title: item.title,
|
||||
status:
|
||||
item.myStatus === "accepted"
|
||||
? "done"
|
||||
: item.myStatus === "tried"
|
||||
? "tried"
|
||||
: ("none" as const),
|
||||
to: `/problem/${item.problemDisplayId}`,
|
||||
}))
|
||||
return {
|
||||
chips,
|
||||
done: list.filter((item) => item.myStatus === "accepted").length,
|
||||
total: list.length,
|
||||
previous: chips[index - 1]?.to ?? null,
|
||||
next: chips[index + 1]?.to ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
onSolvedHere(() => {
|
||||
if (ctx.value.entry !== "problem" || !lessonStore.includes(currentId.value)) return
|
||||
lessonStore.markAccepted(currentId.value)
|
||||
lessonStore.load(true)
|
||||
})
|
||||
|
||||
// ==================== 题单 ====================
|
||||
const problemSet = ref<ProblemSet | null>(null)
|
||||
const setProblems = ref<ProblemSetProblem[]>([])
|
||||
|
||||
watch(
|
||||
() => ctx.value.problemSetId,
|
||||
async (id) => {
|
||||
problemSet.value = null
|
||||
setProblems.value = []
|
||||
if (!id) return
|
||||
try {
|
||||
const [detail, problems] = await Promise.all([
|
||||
getProblemSetDetail(Number(id)),
|
||||
getProblemSetProblems(Number(id)),
|
||||
])
|
||||
// 拉的这一会儿已经离开了这个题单
|
||||
if (ctx.value.problemSetId !== id) return
|
||||
problemSet.value = detail
|
||||
setProblems.value = problems
|
||||
} catch {
|
||||
// 拉不到就没有题单条,不影响做题
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const setBar = computed(() => {
|
||||
if (!problemSet.value) return null
|
||||
const list = setProblems.value
|
||||
const index = list.findIndex(
|
||||
(item) => item.problem._id.toLowerCase() === currentId.value.toLowerCase(),
|
||||
)
|
||||
const link = (item: ProblemSetProblem | undefined): RouteLocationRaw | null =>
|
||||
item
|
||||
? {
|
||||
name: "problemset problem",
|
||||
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
|
||||
}
|
||||
: null
|
||||
const done = list.filter((item) => item.isCompleted).length
|
||||
return {
|
||||
title: problemSet.value.title,
|
||||
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
|
||||
done,
|
||||
total: list.length,
|
||||
percentage: list.length ? (done / list.length) * 100 : 0,
|
||||
position: index >= 0 ? index + 1 : null,
|
||||
previous: index >= 0 ? link(list[index - 1]) : null,
|
||||
next: index >= 0 ? link(list[index + 1]) : null,
|
||||
}
|
||||
})
|
||||
|
||||
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
|
||||
onSolvedHere(() => {
|
||||
const item = setProblems.value.find(
|
||||
(row) => row.problem._id.toLowerCase() === currentId.value.toLowerCase(),
|
||||
)
|
||||
if (item) item.isCompleted = true
|
||||
})
|
||||
|
||||
// ==================== 比赛 ====================
|
||||
const contestStore = useContestStore()
|
||||
|
||||
// 比赛页(contest/detail.vue)和比赛题目页是两条并列的路由,不会同时挂着:
|
||||
// 从比赛页点进来时那边已经 clear 过了,这里自己再拉一遍
|
||||
// 卸载时路由已经是下一页的了,ctx 里的 contestId 不作数,自己记着
|
||||
let contestLoaded = false
|
||||
watch(
|
||||
() => ctx.value.contestId,
|
||||
(id) => {
|
||||
if (!id) return
|
||||
contestLoaded = true
|
||||
contestStore.init(id).catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onBeforeUnmount(() => {
|
||||
// 停掉倒计时的表
|
||||
if (contestLoaded) contestStore.clear()
|
||||
})
|
||||
|
||||
const contestBar = computed(() => {
|
||||
const contest = contestStore.contest
|
||||
if (ctx.value.entry !== "contest" || !contest || String(contest.id) !== ctx.value.contestId)
|
||||
return null
|
||||
const chips: ChipItem[] = contestStore.problems.map((row) => ({
|
||||
displayId: row._id,
|
||||
title: row.title,
|
||||
status: row.status === "passed" ? "done" : row.status === "failed" ? "tried" : "none",
|
||||
to: `/contest/${ctx.value.contestId}/problem/${row._id}`,
|
||||
}))
|
||||
return {
|
||||
title: contest.title,
|
||||
chips,
|
||||
remaining: contestStore.remaining,
|
||||
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
|
||||
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } },
|
||||
}
|
||||
})
|
||||
|
||||
onSolvedHere(() => {
|
||||
const row = contestStore.problems.find(
|
||||
(item) => item._id.toLowerCase() === currentId.value.toLowerCase(),
|
||||
)
|
||||
if (row) row.status = "passed"
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 课堂 -->
|
||||
<div v-if="lesson" class="context-bar">
|
||||
<span class="lead">
|
||||
这节课 <b>做完 {{ lesson.done }}/{{ lesson.total }}</b>
|
||||
</span>
|
||||
<ProblemChips :items="lesson.chips" :current="currentId" />
|
||||
<n-button-group v-if="lesson.total <= CHIPS_FIT" size="small" class="steps">
|
||||
<n-button :disabled="!lesson.previous" title="上一题" @click="$router.push(lesson.previous!)">
|
||||
‹
|
||||
</n-button>
|
||||
<n-button :disabled="!lesson.next" title="下一题" @click="$router.push(lesson.next!)">
|
||||
›
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
|
||||
<!-- 题单 -->
|
||||
<div v-else-if="setBar" class="context-bar">
|
||||
<router-link :to="setBar.home" class="back" :title="`回到题单:${setBar.title}`">
|
||||
<span aria-hidden="true">‹</span>
|
||||
<span class="name">{{ setBar.title }}</span>
|
||||
</router-link>
|
||||
<n-progress
|
||||
class="set-progress"
|
||||
type="line"
|
||||
:percentage="setBar.percentage"
|
||||
:show-indicator="false"
|
||||
:height="6"
|
||||
status="success"
|
||||
/>
|
||||
<span class="lead">
|
||||
<b>做完 {{ setBar.done }}/{{ setBar.total }}</b>
|
||||
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
|
||||
</span>
|
||||
<n-button-group size="small" class="steps">
|
||||
<n-button :disabled="!setBar.previous" title="上一题" @click="$router.push(setBar.previous!)">
|
||||
‹
|
||||
</n-button>
|
||||
<n-button :disabled="!setBar.next" title="下一题" @click="$router.push(setBar.next!)">
|
||||
›
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
|
||||
<!-- 比赛 -->
|
||||
<div v-else-if="contestBar" class="context-bar">
|
||||
<router-link :to="contestBar.home" class="back" :title="`回到比赛:${contestBar.title}`">
|
||||
<span aria-hidden="true">‹</span>
|
||||
<span class="name">{{ contestBar.title }}</span>
|
||||
</router-link>
|
||||
<ProblemChips :items="contestBar.chips" :current="currentId" />
|
||||
<span v-if="contestBar.remaining" class="lead remaining">
|
||||
还剩 <b>{{ contestBar.remaining }}</b>
|
||||
</span>
|
||||
<span v-else class="lead">已结束</span>
|
||||
<router-link :to="contestBar.rank" class="steps">
|
||||
<n-button size="small">排名</n-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-bar {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 2px;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lead {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.remaining b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* 名字不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
|
||||
flex: none;
|
||||
max-width: 160px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.set-progress {
|
||||
flex: 1 1 60px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@ import type { LANGUAGE } from "utils/types"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { NFlex } from "naive-ui"
|
||||
import SubmitCode from "./SubmitCode.vue"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
|
||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||
// 只有老师看得见(下面的弹框挂了 isTeacherOrAbove),静态 import 的话每个学生
|
||||
@@ -38,8 +39,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const ctx = useProblemPageContext()
|
||||
const userStore = useUserStore()
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
@@ -63,8 +64,6 @@ const canRunSamples = computed(
|
||||
codeStore.code.language !== "SQL",
|
||||
)
|
||||
|
||||
// 计算属性
|
||||
const isContestMode = computed(() => route.name === "contest problem")
|
||||
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
|
||||
// 教师端的入口在顶栏,不在题目页
|
||||
@@ -76,7 +75,7 @@ const showHelpButton = computed(
|
||||
// 演示模式下协作通道是断开的(见 App.vue),按钮点了也没人收
|
||||
!userStore.demoMode &&
|
||||
codeStore.code.language !== "Flowchart" &&
|
||||
!isContestMode.value,
|
||||
ctx.value.help,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -124,30 +123,15 @@ const toggleHelp = () => {
|
||||
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
|
||||
}
|
||||
|
||||
const showGoSubmissionButton = computed(() => {
|
||||
if (isContestMode.value) return true
|
||||
else if (userStore.isAdminRole) return true
|
||||
else if (userStore.showSubmissions) return true
|
||||
else return false
|
||||
})
|
||||
|
||||
const menuOptions = computed<DropdownOption[]>(() => {
|
||||
const options: DropdownOption[] = []
|
||||
// 移动端额外收纳桌面端常驻的两项
|
||||
if (!isDesktop.value) {
|
||||
if (showGoSubmissionButton.value) {
|
||||
options.push({
|
||||
label: "本题提交",
|
||||
key: "submissions",
|
||||
})
|
||||
}
|
||||
if (userStore.isTeacherOrAbove) {
|
||||
// 移动端额外收纳桌面端常驻的「课堂统计」。「本题提交」挪到了「我的提交」抽屉的底部
|
||||
if (!isDesktop.value && userStore.isTeacherOrAbove) {
|
||||
options.push({
|
||||
label: "课堂统计",
|
||||
key: "statistics",
|
||||
})
|
||||
}
|
||||
}
|
||||
if (codeStore.code.language !== "Flowchart") {
|
||||
if (codeStore.code.language !== "SQL") {
|
||||
options.push({
|
||||
@@ -179,9 +163,6 @@ const menuOptions = computed<DropdownOption[]>(() => {
|
||||
|
||||
const handleMenuSelect = (key: string) => {
|
||||
switch (key) {
|
||||
case "submissions":
|
||||
goSubmissions()
|
||||
break
|
||||
case "statistics":
|
||||
statisticPanel.value = true
|
||||
break
|
||||
@@ -238,26 +219,14 @@ const goTestCat = () => {
|
||||
const data = {
|
||||
lang,
|
||||
code: codeStore.code.value,
|
||||
input: problemStore.problem?.samples[0].input,
|
||||
// 没有例子的题原来在这里抛 TypeError,点了没反应
|
||||
input: problemStore.problem?.samples[0]?.input ?? "",
|
||||
}
|
||||
const base64 = compressToBase64(JSON.stringify(data))
|
||||
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
const goSubmissions = () => {
|
||||
const name = route.params.contestID ? "contest submissions" : "submissions"
|
||||
const target = { name, query: { problem: problem.value!._id } }
|
||||
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
|
||||
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
|
||||
// 协作时最常点的一个按钮 —— 这是整条工具栏上唯一会跳路由的按钮
|
||||
if (showCollabBar.value) {
|
||||
window.open(router.resolve(target).href, "_blank")
|
||||
return
|
||||
}
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
const goEdit = () => {
|
||||
const url = problem.value!.contestId
|
||||
? `/admin/contest/${problem.value!.contestId}/problem/edit/${problem.value!.id}`
|
||||
@@ -294,10 +263,6 @@ const goEdit = () => {
|
||||
|
||||
<SubmitCode v-else />
|
||||
|
||||
<n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions">
|
||||
本题提交
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="isDesktop && userStore.isTeacherOrAbove"
|
||||
:size="buttonSize"
|
||||
@@ -306,7 +271,7 @@ const goEdit = () => {
|
||||
课堂统计
|
||||
</n-button>
|
||||
|
||||
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上本题提交 / 课堂统计 -->
|
||||
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上课堂统计 -->
|
||||
<n-dropdown
|
||||
v-if="menuOptions.length"
|
||||
trigger="click"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getClassActivity } from "oj/api"
|
||||
import type { ClassActivity } from "utils/types"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
|
||||
/**
|
||||
* 通过之后告诉学生「这节课的下一题是哪道」。
|
||||
*
|
||||
* 72% 的提交一次就通过,每次通过之后学生都得回首页、或者再问一遍题号去找下一道。
|
||||
* 这节课的题就是首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的),
|
||||
* 这里直接拿同一个接口,给出下一道没做完的。
|
||||
* 这节课的题就是首页「班里在做」那几道,和左栏顶上的课堂条读同一份(lesson store);
|
||||
* 通过的那一刻课堂条已经把这道标成做完、并且重拉了一次。
|
||||
*
|
||||
* 「下一道」是按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了再从头找 ——
|
||||
* 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的 myStatus:
|
||||
* 接口是通过之前那一刻的状态,这时候它刚刚才通过。
|
||||
* 「下一道」怎么算见 lesson store 的 nextAfter,AC 后的点评弹窗里那个「下一题」也用它。
|
||||
*
|
||||
* 这道题不在这节课的列表里(学生在做别的)就什么都不显示。
|
||||
*/
|
||||
@@ -20,24 +17,14 @@ const props = defineProps<{
|
||||
problemDisplayId: string
|
||||
}>()
|
||||
|
||||
const activity = ref<ClassActivity | null>(null)
|
||||
const lessonStore = useLessonStore()
|
||||
|
||||
onMounted(async () => {
|
||||
activity.value = await getClassActivity().catch(() => null)
|
||||
})
|
||||
// 一般课堂条早就拉过了,这里只是兜底(旧了才拉)
|
||||
onMounted(() => lessonStore.load())
|
||||
|
||||
const lesson = computed(() => {
|
||||
const list = activity.value?.problems ?? []
|
||||
const current = props.problemDisplayId.toLowerCase()
|
||||
const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === current)
|
||||
if (index < 0) return null
|
||||
const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter(
|
||||
(item) => item.myStatus !== "accepted",
|
||||
)
|
||||
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
|
||||
})
|
||||
const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
|
||||
|
||||
const label = computed(() => (activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题"))
|
||||
const label = computed(() => lessonStore.label)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
|
||||
/**
|
||||
* 学生自己画到 A/S 的那张流程图。题面里是缩略的一小块(compact),点「放大看」弹出整张。
|
||||
* 原来是左栏单独一个「我的流程图」页签,评到 A/S 时还会把学生强行切过去。
|
||||
*/
|
||||
const props = defineProps<{ compact?: boolean }>()
|
||||
|
||||
const store = useMyFlowchartStore()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
watch(
|
||||
() => store.mermaidCode,
|
||||
async (code) => {
|
||||
if (!code) return
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, code)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
class="flowchart-container"
|
||||
:class="{ compact: props.compact }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flowchart-container {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.flowchart-container > svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.flowchart-container.compact {
|
||||
min-height: 0;
|
||||
height: 150px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 缩略图按高度缩:流程图一般是竖长的,按宽度铺满的话 150px 里只看得见开头两个框 */
|
||||
:deep(.flowchart-container.compact > svg) {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
|
||||
const store = useMyFlowchartStore()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
watch(
|
||||
() => store.mermaidCode,
|
||||
async (code) => {
|
||||
if (!code) return
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, code)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="padding: 8px 0">
|
||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div v-show="!renderError" ref="mermaidContainer" class="flowchart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flowchart-container {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.flowchart-container > svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { RouteLocationRaw } from "vue-router"
|
||||
|
||||
/**
|
||||
* 上下文条里的一排题号(课堂、比赛共用)。只放题号,题目名在鼠标停上去时给。
|
||||
*
|
||||
* 状态用图标区分,不只靠颜色:✓ 做完(实心绿)/ ! 做过没对(橙)/ 灰底 没做;当前题描边。
|
||||
* 一节课一般 5 道以内,一行放得下;多了(比赛、老师一次布置了一长串)就横向滚动,
|
||||
* 当前题保持在视野里。
|
||||
*/
|
||||
export interface ChipItem {
|
||||
displayId: string
|
||||
title: string
|
||||
status: "done" | "tried" | "none"
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: ChipItem[]
|
||||
current: string
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
const STATUS_TEXT = { done: "做完了", tried: "做过,还没对", none: "还没做" } as const
|
||||
|
||||
const isCurrent = (item: ChipItem) => item.displayId.toLowerCase() === props.current.toLowerCase()
|
||||
|
||||
const scroller = useTemplateRef<HTMLElement>("scroller")
|
||||
|
||||
// 换题之后把当前那一颗滚进来。`nearest`:本来就看得见就不动,免得一换题整排跳一下
|
||||
watch(
|
||||
() => [props.current, props.items.length],
|
||||
async () => {
|
||||
await nextTick()
|
||||
scroller.value
|
||||
?.querySelector<HTMLElement>(".chip.current")
|
||||
?.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scroller" class="chips">
|
||||
<n-tooltip v-for="item in items" :key="item.displayId" :delay="300">
|
||||
<template #trigger>
|
||||
<router-link
|
||||
:to="item.to"
|
||||
class="chip"
|
||||
:class="[item.status, { current: isCurrent(item) }]"
|
||||
:aria-label="`${item.displayId} ${item.title}(${STATUS_TEXT[item.status]})`"
|
||||
:aria-current="isCurrent(item) ? 'page' : undefined"
|
||||
>
|
||||
<!-- 用字符不用图标:图标是从 Iconify 源现拉的,拉不到时状态就只剩颜色 -->
|
||||
<span v-if="item.status === 'done'" aria-hidden="true">✓</span>
|
||||
<span v-else-if="item.status === 'tried'" aria-hidden="true">!</span>
|
||||
<span>{{ item.displayId }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
{{ item.displayId }} {{ item.title }} · {{ STATUS_TEXT[item.status] }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
/* 描边的外圈别被裁掉 */
|
||||
padding: 3px 2px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 13px;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-decoration: none;
|
||||
color: v-bind("theme.textColor2");
|
||||
background-color: rgba(128, 128, 128, 0.12);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.chip.done {
|
||||
color: #fff;
|
||||
background-color: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.chip.tried {
|
||||
color: v-bind("theme.warningColor");
|
||||
background-color: rgba(240, 160, 32, 0.12);
|
||||
border-color: rgba(240, 160, 32, 0.5);
|
||||
}
|
||||
|
||||
.chip.current {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
box-shadow: 0 0 0 1px v-bind("theme.primaryColor");
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 当前题做完了:实心绿,外面再套一圈(中间隔一道底色) */
|
||||
.chip.done.current {
|
||||
border-color: v-bind("theme.successColor");
|
||||
box-shadow:
|
||||
0 0 0 2px v-bind("theme.bodyColor"),
|
||||
0 0 0 3px v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -12,13 +12,18 @@ import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { getSimilarProblems } from "oj/api"
|
||||
import SQLDataTable from "./SQLDataTable.vue"
|
||||
import { useFlowchartStore } from "oj/store/flowchart"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
|
||||
const ProblemFlowchart = defineAsyncComponent(() => import("./ProblemFlowchart.vue"))
|
||||
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
|
||||
|
||||
const isDark = useDark()
|
||||
const route = useRoute()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const problemSetId = computed(() => route.params.problemSetId)
|
||||
const { problem, languages } = storeToRefs(problemStore)
|
||||
const ctx = useProblemPageContext()
|
||||
|
||||
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
||||
const isSQL = computed(() => !!problem.value?.sqlConfig)
|
||||
@@ -40,9 +45,8 @@ const similarLoaded = ref(false)
|
||||
|
||||
async function loadSimilarProblems() {
|
||||
if (similarLoaded.value || !problem.value) return
|
||||
// 比赛题不推荐:接口按 displayId 在**公开题库**里找,比赛题的编号默认是 1/2/3,
|
||||
// 一般白跑一趟 404,撞上同号公开题时反而会在比赛中把题库列给学生。
|
||||
if (problem.value.contestId !== null) return
|
||||
// 比赛、题单里不推荐(理由见 problemPageContext 那张表)
|
||||
if (!ctx.value.similar) return
|
||||
try {
|
||||
similarProblems.value = await getSimilarProblems(problem.value._id)
|
||||
} catch {
|
||||
@@ -71,14 +75,43 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const hasTriedButNotPassed = computed(() => {
|
||||
return (
|
||||
problem.value?.myStatus !== undefined &&
|
||||
problem.value?.myStatus !== null &&
|
||||
problem.value?.myStatus !== 0
|
||||
)
|
||||
/**
|
||||
* 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有
|
||||
* 通过」),占掉一屏里最值钱的那几行。题单里不给(入口差异表)。
|
||||
*/
|
||||
const myStatus = computed(() => {
|
||||
const status = problem.value?.myStatus
|
||||
if (!ctx.value.statusTag || status === undefined || status === null) return null
|
||||
return status === 0 ? "solved" : "tried"
|
||||
})
|
||||
|
||||
/** 「限时 1 秒」:学生不需要 ms / MB。1000 → 1,1500 → 1.5 */
|
||||
const timeLimitText = computed(() => {
|
||||
const ms = problem.value?.timeLimit ?? 0
|
||||
return `限时 ${Number((ms / 1000).toFixed(2))} 秒`
|
||||
})
|
||||
|
||||
// ==================== 流程图小节 ====================
|
||||
// 同一个位置二选一:老师给的参考图(showFlowchart,默认折叠),或者学生自己画到 A/S 的
|
||||
// 那张(allowFlowchart)。两个开关在后台是互斥的(admin/problem/detail.vue);后端在
|
||||
// allowFlowchart 为真时会把 mermaidCode 置成 null,不能把标准答案下发给正要自己画图的学生。
|
||||
const canShowReferenceFlowchart = computed(
|
||||
() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode,
|
||||
)
|
||||
const myFlowchartStore = useMyFlowchartStore()
|
||||
const flowchartStore = useFlowchartStore()
|
||||
const userStore = useUserStore()
|
||||
const myFlowchartZoom = ref(false)
|
||||
|
||||
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查
|
||||
watch(
|
||||
() => [problem.value?.id, languages.value.includes("Flowchart"), userStore.isAuthed] as const,
|
||||
([id, canDraw, authed]) => {
|
||||
if (id && canDraw && authed) flowchartStore.ensureLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位),
|
||||
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
|
||||
const samples = computed(() => problem.value?.samples ?? [])
|
||||
@@ -98,27 +131,6 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
|
||||
<template>
|
||||
<div v-if="problem">
|
||||
<template v-if="!problemSetId">
|
||||
<!-- 已通过 -->
|
||||
<n-alert
|
||||
class="status-alert"
|
||||
v-if="problem.myStatus === 0"
|
||||
type="success"
|
||||
title="🎉 本 题 已 经 被 你 解 决 啦"
|
||||
>
|
||||
</n-alert>
|
||||
|
||||
<!-- 尝试过但未通过 -->
|
||||
<n-alert
|
||||
class="status-alert"
|
||||
v-else-if="hasTriedButNotPassed"
|
||||
type="warning"
|
||||
title="💪 你已经尝试过这道题,但还没有通过"
|
||||
>
|
||||
不要放弃!仔细检查代码逻辑,或者寻求 AI 的帮助获取灵感。
|
||||
</n-alert>
|
||||
</template>
|
||||
|
||||
<header class="problem-head">
|
||||
<n-flex align="center" :size="10" :wrap="false">
|
||||
<n-tag :bordered="false">{{ problem._id }}</n-tag>
|
||||
@@ -133,9 +145,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
>
|
||||
{{ DIFFICULTY[problem.difficulty] }}
|
||||
</n-tag>
|
||||
<n-text depth="3" v-if="!isSQL">
|
||||
时间限制 {{ problem.timeLimit }} ms · 内存限制 {{ problem.memoryLimit }} MB
|
||||
</n-text>
|
||||
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" round>
|
||||
✓ 已解决
|
||||
</n-tag>
|
||||
<n-tag v-else-if="myStatus === 'tried'" size="small" type="warning" :bordered="false" round>
|
||||
! 尝试过,还没通过
|
||||
</n-tag>
|
||||
<n-text depth="3" v-if="!isSQL">{{ timeLimitText }}</n-text>
|
||||
</n-flex>
|
||||
</header>
|
||||
|
||||
@@ -188,16 +204,53 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
/>
|
||||
</template>
|
||||
|
||||
<n-collapse v-if="canShowReferenceFlowchart" class="flowchart-section">
|
||||
<n-collapse-item name="reference">
|
||||
<template #header>
|
||||
<span class="title collapse-title">
|
||||
<Icon icon="vscode-icons:file-type-drawio"></Icon>
|
||||
参考流程图
|
||||
</span>
|
||||
</template>
|
||||
<ProblemFlowchart />
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<section v-else-if="myFlowchartStore.showing" class="flowchart-section">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<h3 class="title">
|
||||
<Icon icon="vscode-icons:file-type-drawio"></Icon>
|
||||
你画的流程图
|
||||
</h3>
|
||||
<n-button text type="primary" size="small" @click="myFlowchartZoom = true">
|
||||
放大看 ›
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div class="my-flowchart" role="button" @click="myFlowchartZoom = true">
|
||||
<MyFlowchart compact />
|
||||
</div>
|
||||
<n-modal
|
||||
v-model:show="myFlowchartZoom"
|
||||
preset="card"
|
||||
title="你画的流程图"
|
||||
:style="{ maxWidth: '900px' }"
|
||||
>
|
||||
<MyFlowchart />
|
||||
</n-modal>
|
||||
</section>
|
||||
|
||||
<template v-if="!isSQL">
|
||||
<section v-for="(sample, index) of samples" :key="index" class="sample">
|
||||
<!--
|
||||
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子
|
||||
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」
|
||||
-->
|
||||
<section v-if="samples.length" class="sample">
|
||||
<n-flex align="center" justify="space-between" class="sample-head">
|
||||
<h3 class="title">
|
||||
<Icon icon="streamline-emojis:microscope"></Icon>
|
||||
例子 {{ index + 1 }}
|
||||
例子
|
||||
</h3>
|
||||
<!-- 只在第一个例子旁放一个入口:每个都放就成了一排一样的链接 -->
|
||||
<n-button
|
||||
v-if="index === 0"
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -206,20 +259,22 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
自己输入数据试试 ›
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div class="sample-grid">
|
||||
<div class="sample-box">
|
||||
<n-flex align="center" justify="space-between" class="sample-label">
|
||||
<span>输入</span>
|
||||
<Copy :value="sample.input" />
|
||||
</n-flex>
|
||||
<pre class="testcase">{{ sample.input }}</pre>
|
||||
<div class="sample-table" role="table" aria-label="例子">
|
||||
<div class="sample-row sample-header" role="row">
|
||||
<span role="columnheader"></span>
|
||||
<span role="columnheader">输入</span>
|
||||
<span role="columnheader">输出</span>
|
||||
</div>
|
||||
<div class="sample-box">
|
||||
<n-flex align="center" justify="space-between" class="sample-label">
|
||||
<span>输出</span>
|
||||
<Copy :value="sample.output" />
|
||||
</n-flex>
|
||||
<div v-for="(sample, index) of samples" :key="index" class="sample-row" role="row">
|
||||
<span class="sample-no" role="rowheader">{{ index + 1 }}</span>
|
||||
<div class="sample-cell" role="cell">
|
||||
<pre class="testcase">{{ sample.input }}</pre>
|
||||
<span class="sample-copy"><Copy :value="sample.input" /></span>
|
||||
</div>
|
||||
<div class="sample-cell" role="cell">
|
||||
<pre class="testcase">{{ sample.output }}</pre>
|
||||
<span class="sample-copy"><Copy :value="sample.output" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -292,7 +347,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
</div>
|
||||
|
||||
<!-- 相似题目推荐 -->
|
||||
<div v-if="similarProblems.length > 0">
|
||||
<div v-if="ctx.similar && similarProblems.length > 0">
|
||||
<n-divider />
|
||||
<h3 class="title">
|
||||
<Icon icon="streamline-ultimate-color:like"></Icon>
|
||||
@@ -420,23 +475,59 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sample-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sample-box {
|
||||
min-width: 0;
|
||||
.sample-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
padding: 6px 10px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sample-label {
|
||||
.sample-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.2em minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sample-row + .sample-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.sample-header {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
background-color: rgba(128, 128, 128, 0.06);
|
||||
}
|
||||
|
||||
.sample-header > span {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.sample-no {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background-color: rgba(128, 128, 128, 0.06);
|
||||
}
|
||||
|
||||
.sample-cell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 6px 30px 6px 10px;
|
||||
}
|
||||
|
||||
.sample-cell + .sample-cell {
|
||||
border-left: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
/* Copy 的根是 n-tooltip,class 挂不上去,所以外面包一层 span 来定位 */
|
||||
.sample-copy {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.testcase {
|
||||
@@ -447,8 +538,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
font-family: Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.status-alert {
|
||||
margin-bottom: 16px;
|
||||
.flowchart-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-flowchart {
|
||||
cursor: zoom-in;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.06);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sqlTableName {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
DRAWER_TITLE,
|
||||
useProblemPageContext,
|
||||
type ProblemDrawer,
|
||||
} from "../composables/problemPageContext"
|
||||
|
||||
/**
|
||||
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
|
||||
* 现在收进抽屉,盖住左栏、编辑器不动。
|
||||
*
|
||||
* 抽屉里的内容关上就卸载:再打开时「我的提交」要重新拉,刚交的那次才看得见。
|
||||
*/
|
||||
const ProblemInfo = defineAsyncComponent(() => import("./ProblemInfo.vue"))
|
||||
const ProblemReaction = defineAsyncComponent(() => import("./ProblemReaction.vue"))
|
||||
const ProblemSubmission = defineAsyncComponent(() => import("./ProblemSubmission.vue"))
|
||||
|
||||
const open = defineModel<ProblemDrawer | null>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 桌面:挂进左栏(它得是 position: relative);手机:不传,从底下拉起来盖住整屏 */
|
||||
to?: string
|
||||
}>()
|
||||
|
||||
const ctx = useProblemPageContext()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const collabStore = useCollabStore()
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
|
||||
const show = computed({
|
||||
get: () => open.value !== null,
|
||||
set: (value) => {
|
||||
if (!value) open.value = null
|
||||
},
|
||||
})
|
||||
|
||||
// 内容和标题都按最后一次打开的来:关的时候有一段滑出去的动画,那时 open 已经是 null 了。
|
||||
// 关完 n-drawer 自己把内容卸掉
|
||||
const lastOpened = ref<ProblemDrawer>("info")
|
||||
watch(open, (key) => {
|
||||
if (key) lastOpened.value = key
|
||||
})
|
||||
|
||||
// 换题就关:抽屉里是上一道题的统计和提交
|
||||
watch(
|
||||
() => problem.value?._id,
|
||||
() => {
|
||||
open.value = null
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 「看这道题所有人的提交」,原来是工具栏上的「本题提交」。可见条件照旧:
|
||||
* 比赛里总给(看的是这场比赛的提交),题库里要管理员或者开着提交列表。
|
||||
*/
|
||||
const showAllSubmissions = computed(
|
||||
() => ctx.value.entry === "contest" || userStore.isAdminRole || userStore.showSubmissions,
|
||||
)
|
||||
|
||||
function goAllSubmissions() {
|
||||
const target = {
|
||||
name: ctx.value.entry === "contest" ? "contest submissions" : "submissions",
|
||||
params: ctx.value.entry === "contest" ? { contestID: route.params.contestID } : {},
|
||||
query: { problem: problem.value!._id },
|
||||
}
|
||||
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
|
||||
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
|
||||
// 协作时最常点的一个
|
||||
const collabHere =
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value?._id &&
|
||||
userStore.isTeacherOrAbove
|
||||
if (collabHere) {
|
||||
window.open(router.resolve(target).href, "_blank")
|
||||
return
|
||||
}
|
||||
router.push(target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-drawer
|
||||
v-model:show="show"
|
||||
:to="props.to"
|
||||
:placement="props.to ? 'left' : 'bottom'"
|
||||
:width="props.to ? '100%' : undefined"
|
||||
:height="props.to ? undefined : '85%'"
|
||||
:trap-focus="false"
|
||||
:block-scroll="!props.to"
|
||||
>
|
||||
<n-drawer-content :title="DRAWER_TITLE[lastOpened]" closable :native-scrollbar="false">
|
||||
<ProblemInfo v-if="lastOpened === 'info'" />
|
||||
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
|
||||
<template v-else>
|
||||
<ProblemSubmission />
|
||||
<n-button
|
||||
v-if="showAllSubmissions"
|
||||
class="all-submissions"
|
||||
text
|
||||
type="primary"
|
||||
@click="goAllSubmissions"
|
||||
>
|
||||
看这道题所有人的提交 ›
|
||||
</n-button>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.all-submissions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,315 +1,372 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton, NFlex, NTooltip } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { getSubmissions, getRankOfProblem } from "oj/api"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { SubmissionListItem } from "utils/types"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { copyToClipboard, parseTime } from "utils/functions"
|
||||
import type { ProblemRank } from "@oj2/contract"
|
||||
import type { LANGUAGE, SubmissionListItem } from "utils/types"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
|
||||
/**
|
||||
* 「我的提交」抽屉:这道题自己交过的每一次。
|
||||
*
|
||||
* 原来是一张表(提交时间 / 编号 / 状态 / 语言),点编号弹出一个 70vw 的提交详情弹窗,
|
||||
* 盖住整个题目页;「复制回到题目」还会整页跳一次路由。现在每一行点开就在行内看代码,
|
||||
* 「放回编辑器」直接换掉右边编辑器里的代码(按 Ctrl+Z 能撤回),抽屉不关、不跳页。
|
||||
*/
|
||||
|
||||
const userStore = useUserStore()
|
||||
const codeStore = useCodeStore()
|
||||
const collabStore = useCollabStore()
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const ctx = useProblemPageContext()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const message = useMessage()
|
||||
|
||||
// 弹框状态管理
|
||||
const [codePanelVisible, toggleCodePanel] = useToggle(false)
|
||||
const submissionID = ref("")
|
||||
const problemID = ref("")
|
||||
const problemDisplayId = computed(() => String(route.params.problemID ?? ""))
|
||||
|
||||
// 显示代码弹框
|
||||
function showCodePanel(id: string, problem: string) {
|
||||
submissionID.value = id
|
||||
problemID.value = problem
|
||||
toggleCodePanel(true)
|
||||
/**
|
||||
* 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的
|
||||
* 那一份,所以不给(设计文档第 9 节)。
|
||||
*/
|
||||
const teacherCollab = computed(
|
||||
() =>
|
||||
userStore.isTeacherOrAbove &&
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
|
||||
// ==================== 班上第几个做对的 ====================
|
||||
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现
|
||||
const rank = ref<ProblemRank | null>(null)
|
||||
|
||||
const rankLine = computed(() => {
|
||||
const r = rank.value
|
||||
if (!r) return null
|
||||
const inClass = !!r.className
|
||||
const scope = inClass ? "班上" : "全站"
|
||||
const count = inClass ? r.classAcCount : r.allAcCount
|
||||
if (r.rank !== -1)
|
||||
return {
|
||||
text: `你是${scope}第 ${r.rank} 个做对的,${scope}一共 ${count} 人做对了`,
|
||||
solved: true,
|
||||
}
|
||||
if (count > 0)
|
||||
return { text: `${inClass ? "你们班" : "全站"}已经有 ${count} 人做对了`, solved: false }
|
||||
return null
|
||||
})
|
||||
|
||||
/** 「看看他们的写法」:同班的走 'ks' + 班级名的用户名前缀约定(设计文档第 13 节) */
|
||||
function goAccepted() {
|
||||
const r = rank.value
|
||||
if (!r) return
|
||||
const target = {
|
||||
name: "submissions",
|
||||
query: {
|
||||
problem: problemDisplayId.value,
|
||||
result: "0",
|
||||
page: 1,
|
||||
limit: 10,
|
||||
...(r.className ? { username: "ks" + r.className } : {}),
|
||||
},
|
||||
}
|
||||
// 协作中跳走这一页协作就断了
|
||||
if (teacherCollab.value) window.open(router.resolve(target).href, "_blank")
|
||||
else router.push(target)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<SubmissionListItem>[] = [
|
||||
{
|
||||
title: "提交时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "编号",
|
||||
key: "id",
|
||||
minWidth: 160,
|
||||
render: (row) => {
|
||||
if (!row.showLink)
|
||||
return h(NFlex, { align: "center" }, () => [
|
||||
h("span", row.id.slice(0, 12)),
|
||||
h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () => h(NButton, { text: true }, () => h(Icon, { icon: "catppuccin:lock" })),
|
||||
default: () =>
|
||||
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
|
||||
},
|
||||
),
|
||||
])
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => {
|
||||
showCodePanel(row.id, (route.params.problemID as string) ?? "")
|
||||
},
|
||||
},
|
||||
() => row.id.slice(0, 12),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 140,
|
||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||
},
|
||||
{
|
||||
title: "语言",
|
||||
key: "language",
|
||||
width: 100,
|
||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||
},
|
||||
]
|
||||
|
||||
const class_name = ref("")
|
||||
const rank = ref(-1)
|
||||
const class_ac_count = ref(0)
|
||||
const all_ac_count = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// ==================== 列表 ====================
|
||||
const submissions = ref<SubmissionListItem[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
// 拉回来之前别说「还没交过」
|
||||
const listed = ref(false)
|
||||
const query = reactive({ limit: 10, page: 1 })
|
||||
|
||||
// 错误分布统计
|
||||
const statusDistribution = computed(() => {
|
||||
if (!submissions.value.length) return []
|
||||
const counts = new Map<number, number>()
|
||||
for (const s of submissions.value) {
|
||||
counts.set(s.result, (counts.get(s.result) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([result, count]) => ({
|
||||
result,
|
||||
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name || "未知",
|
||||
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type || "info",
|
||||
count,
|
||||
}))
|
||||
})
|
||||
|
||||
const errorMsg = computed(() => {
|
||||
if (!userStore.isAuthed) return "请先登录"
|
||||
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
||||
else return ""
|
||||
const blocked = computed(() => {
|
||||
if (!userStore.isAuthed) return "登录之后才能看到自己的提交"
|
||||
if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
||||
return ""
|
||||
})
|
||||
|
||||
async function listSubmissions() {
|
||||
const offset = query.limit * (query.page - 1)
|
||||
const res = await getSubmissions({
|
||||
...query,
|
||||
myself: "1",
|
||||
offset,
|
||||
problemDisplayId: (route.params.problemID as string) ?? "",
|
||||
contestId: (route.params.contestID as string) ?? "",
|
||||
offset: query.limit * (query.page - 1),
|
||||
problemDisplayId: problemDisplayId.value,
|
||||
contestId: ctx.value.contestId,
|
||||
})
|
||||
submissions.value = res.results
|
||||
total.value = res.total
|
||||
listed.value = true
|
||||
}
|
||||
|
||||
async function getRankOfThisProblem() {
|
||||
loading.value = true
|
||||
const res = await getRankOfProblem((route.params.problemID as string) ?? "")
|
||||
loading.value = false
|
||||
/** 列表是新的在前;「第几次」从最早那次数起 */
|
||||
function attemptNo(index: number) {
|
||||
return total.value - (query.page - 1) * query.limit - index
|
||||
}
|
||||
|
||||
class_name.value = res.className
|
||||
rank.value = res.rank
|
||||
class_ac_count.value = res.classAcCount
|
||||
all_ac_count.value = res.allAcCount
|
||||
// ==================== 行内展开 ====================
|
||||
const expanded = ref<string | null>(null)
|
||||
type Loaded = { code: string; language: LANGUAGE }
|
||||
const details = reactive<Record<string, Loaded | "loading" | "failed">>({})
|
||||
|
||||
async function toggle(row: SubmissionListItem) {
|
||||
// 加入题单之前的提交被锁住,点不开(后端也不会给代码)
|
||||
if (!row.showLink) return
|
||||
if (expanded.value === row.id) {
|
||||
expanded.value = null
|
||||
return
|
||||
}
|
||||
expanded.value = row.id
|
||||
if (details[row.id] && details[row.id] !== "failed") return
|
||||
details[row.id] = "loading"
|
||||
try {
|
||||
const res = await getSubmission(row.id)
|
||||
details[row.id] = { code: res.code, language: res.language }
|
||||
} catch {
|
||||
details[row.id] = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
function loadedOf(id: string): Loaded | null {
|
||||
const value = details[id]
|
||||
return value && typeof value === "object" ? value : null
|
||||
}
|
||||
|
||||
function putBack(id: string) {
|
||||
const loaded = loadedOf(id)
|
||||
if (!loaded) return
|
||||
// 同一个编辑器里 dispatch 一次整段替换,CodeMirror 的历史记着它,Ctrl+Z 能撤回
|
||||
codeStore.setLanguage(loaded.language)
|
||||
codeStore.setCode(loaded.code)
|
||||
message.success("已放回编辑器,按 Ctrl+Z 可以撤回")
|
||||
}
|
||||
|
||||
async function copy(id: string) {
|
||||
const loaded = loadedOf(id)
|
||||
if (!loaded) return
|
||||
const ok = await copyToClipboard(loaded.code)
|
||||
message[ok ? "success" : "error"](ok ? "代码已复制" : "复制失败")
|
||||
}
|
||||
|
||||
/** 完整详情(测试点、自测猫、分享)开新标签:这一页的编辑器和结果还要留着 */
|
||||
function openDetail(id: string) {
|
||||
window.open(router.resolve(`/submission/${id}`).href, "_blank")
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (blocked.value) return
|
||||
listSubmissions()
|
||||
if (route.name === "problem") {
|
||||
getRankOfThisProblem()
|
||||
if (ctx.value.entry === "problem") {
|
||||
getRankOfProblem(problemDisplayId.value)
|
||||
.then((res) => {
|
||||
rank.value = res
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
watch(query, listSubmissions)
|
||||
watch(query, () => {
|
||||
expanded.value = null
|
||||
listSubmissions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
v-if="!userStore.showSubmissions || !userStore.isAuthed"
|
||||
:title="errorMsg"
|
||||
/>
|
||||
<n-alert v-if="blocked" type="warning" :show-icon="false">{{ blocked }}</n-alert>
|
||||
|
||||
<template v-if="!loading && route.name === 'problem' && userStore.isAuthed">
|
||||
<template v-if="class_name">
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你在班上排名第 <b>{{ rank }}</b
|
||||
>,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
username: 'ks' + class_name,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert class="tip" type="error" :show-icon="false" v-if="rank === -1 && class_ac_count > 0">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你还没有解决,你们班共有
|
||||
<b>{{ class_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
username: 'ks' + class_name,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你在全服排名第 <b>{{ rank }}</b
|
||||
>,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
<div v-if="rankLine" class="rank" :class="{ solved: rankLine.solved }">
|
||||
<span>{{ rankLine.text }}</span>
|
||||
<n-button v-if="userStore.showSubmissions" text type="primary" @click="goAccepted">
|
||||
看看他们的写法 ›
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert class="tip" type="error" :show-icon="false" v-if="rank === -1 && all_ac_count > 0">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你还没有解决,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||
<!-- 错误分布统计 -->
|
||||
<n-flex v-if="statusDistribution.length" class="tip" align="center" :wrap="true">
|
||||
<span style="font-weight: bold; font-size: 13px">我的提交统计:</span>
|
||||
<n-tag
|
||||
v-for="item in statusDistribution"
|
||||
:key="item.result"
|
||||
:type="item.type"
|
||||
<n-empty v-if="listed && !submissions.length" class="empty" description="这道题你还没有交过" />
|
||||
|
||||
<ul v-else class="list">
|
||||
<li v-for="(row, index) in submissions" :key="row.id" class="item">
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
:class="{ open: expanded === row.id, locked: !row.showLink }"
|
||||
:disabled="!row.showLink"
|
||||
:aria-expanded="row.showLink ? expanded === row.id : undefined"
|
||||
@click="toggle(row)"
|
||||
>
|
||||
<span class="attempt">第 {{ attemptNo(index) }} 次</span>
|
||||
<SubmissionResultTag :result="row.result" />
|
||||
<span class="meta">{{ LANGUAGE_SHOW_VALUE[row.language] }}</span>
|
||||
<span class="meta time">{{ parseTime(row.createTime, "MM-DD HH:mm") }}</span>
|
||||
<span v-if="row.showLink" class="caret" aria-hidden="true">
|
||||
{{ expanded === row.id ? "▾" : "▸" }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p v-if="!row.showLink" class="locked-note">
|
||||
🔒 这是加入题单之前交的,先藏起来了:在题单里做出这道题就解锁,题单过了截止时间也会解锁
|
||||
</p>
|
||||
|
||||
<div v-else-if="expanded === row.id" class="detail">
|
||||
<n-flex v-if="details[row.id] === 'loading'" justify="center" class="pad">
|
||||
<n-spin size="small" />
|
||||
</n-flex>
|
||||
<n-text v-else-if="details[row.id] === 'failed'" type="error">
|
||||
代码读不出来,收起来再点开试试
|
||||
</n-text>
|
||||
<template v-else-if="loadedOf(row.id)">
|
||||
<pre class="code">{{ loadedOf(row.id)!.code }}</pre>
|
||||
<n-flex :size="8" align="center">
|
||||
<n-button
|
||||
v-if="!teacherCollab"
|
||||
size="small"
|
||||
round
|
||||
type="primary"
|
||||
secondary
|
||||
@click="putBack(row.id)"
|
||||
>
|
||||
{{ item.name }} × {{ item.count }}
|
||||
</n-tag>
|
||||
放回编辑器
|
||||
</n-button>
|
||||
<n-button size="small" @click="copy(row.id)">复制</n-button>
|
||||
<n-button size="small" text class="more" @click="openDetail(row.id)">
|
||||
完整详情 ›
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table v-if="submissions.length > 0" striped :columns="columns" :data="submissions" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 代码详情弹框 -->
|
||||
<n-modal
|
||||
v-model:show="codePanelVisible"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
:problemID="problemID"
|
||||
:submissionID="submissionID"
|
||||
hideList
|
||||
@copied="toggleCodePanel(false)"
|
||||
<Pagination
|
||||
v-if="total > query.limit"
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tip {
|
||||
margin-bottom: 16px;
|
||||
.rank {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rank.solved {
|
||||
background-color: rgba(24, 160, 88, 0.1);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover:not(:disabled),
|
||||
.row.open {
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
}
|
||||
|
||||
.row.locked {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.attempt {
|
||||
flex: none;
|
||||
min-width: 4.5em;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.caret {
|
||||
flex: none;
|
||||
width: 1em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.locked-note {
|
||||
margin: 0;
|
||||
padding: 0 12px 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 4px 12px 12px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.pad {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 8px 0 10px;
|
||||
padding: 10px 12px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
font-family: Monaco, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.more {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -5,8 +5,9 @@ import { getReaction } from "oj/api"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
|
||||
/**
|
||||
* 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回题单。
|
||||
@@ -24,16 +25,12 @@ const submissionStore = useSubmissionStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const { submission } = storeToRefs(submissionStore)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// 题目页在题库 / 比赛 / 题单三条路由之间是复用的,所以跟着路由算,不在 setup 时取一次
|
||||
const contestID = computed(() => (route.params.contestID as string) ?? "")
|
||||
const problemSetId = computed(() => (route.params.problemSetId as string) ?? "")
|
||||
const ctx = useProblemPageContext()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const { celebrate } = useFireworks()
|
||||
|
||||
// ==================== AC 后弹出点评轮盘 ====================
|
||||
// ==================== AC 后弹出点评 ====================
|
||||
// 只对已经能评价、且还没评过的人弹:后端要求先有 AC 才收评价,这里刚 AC 完正好;
|
||||
// mine 非 null 说明早就评过了,别再打扰。
|
||||
//
|
||||
@@ -52,6 +49,7 @@ async function openReview() {
|
||||
try {
|
||||
const res = await getReaction(problem.value!.id)
|
||||
if (res.mine === null) {
|
||||
reviewed.value = false
|
||||
commentPanel.value = true
|
||||
return
|
||||
}
|
||||
@@ -78,6 +76,46 @@ function closeCommentPanel() {
|
||||
settleReview()
|
||||
}
|
||||
|
||||
/**
|
||||
* 评完不马上关:弹窗里换成大家怎么选的,下面只有一个按钮,焦点落在它上面。
|
||||
* 按钮去哪:刚才点「下一题」之类被拦下的,就接着去那里;否则是这节课的下一题
|
||||
* (和结果页签里 LessonNext 同一个算法);都没有就是「好的」,关掉回到结果页签。
|
||||
*/
|
||||
const reviewed = ref(false)
|
||||
const continueButton = useTemplateRef<{ $el: HTMLElement }>("continueButton")
|
||||
const lessonStore = useLessonStore()
|
||||
|
||||
// navigationAfterReview 是普通变量(守卫里改,不需要触发渲染),按钮文案要一个响应式的影子
|
||||
const navigationAfterReviewShown = ref(false)
|
||||
|
||||
/** 题目名中位 7 字、最长 33 字,按钮里放不下那么长的 */
|
||||
const shortTitle = (title: string) => (title.length > 14 ? `${title.slice(0, 13)}…` : title)
|
||||
|
||||
const continueAction = computed(() => {
|
||||
if (navigationAfterReviewShown.value) return { label: "继续", target: null }
|
||||
const lesson = problem.value ? lessonStore.nextAfter(problem.value._id) : null
|
||||
const next = ctx.value.lessonNext ? lesson?.next : null
|
||||
if (next)
|
||||
return {
|
||||
label: `下一题:${next.problemDisplayId} ${shortTitle(next.title)}`,
|
||||
target: `/problem/${next.problemDisplayId}`,
|
||||
}
|
||||
return { label: "好的", target: null }
|
||||
})
|
||||
|
||||
async function onReviewed() {
|
||||
reviewed.value = true
|
||||
navigationAfterReviewShown.value = navigationAfterReview !== null
|
||||
await nextTick()
|
||||
continueButton.value?.$el.focus()
|
||||
}
|
||||
|
||||
function continueAfterReview() {
|
||||
const target = continueAction.value.target
|
||||
if (target && !navigationAfterReview) navigationAfterReview = target
|
||||
closeCommentPanel()
|
||||
}
|
||||
|
||||
const { start: showCommentPanelDelayed, stop: cancelCommentPanel } = useTimeoutFn(
|
||||
openReview,
|
||||
1500,
|
||||
@@ -106,7 +144,7 @@ const { start: goToProblemSetDelayed, stop: cancelGoToProblemSet } = useTimeoutF
|
||||
router.push({
|
||||
name: "problemset",
|
||||
params: {
|
||||
problemSetId: problemSetId.value,
|
||||
problemSetId: ctx.value.problemSetId,
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -176,12 +214,12 @@ watch(
|
||||
celebrate()
|
||||
|
||||
// 3. 弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉
|
||||
if (!contestID.value && !problemSetId.value) {
|
||||
if (ctx.value.reviewAfterAccepted) {
|
||||
reviewOwed.value = true
|
||||
showCommentPanelDelayed()
|
||||
}
|
||||
|
||||
if (problemSetId.value) {
|
||||
if (ctx.value.backToProblemSet) {
|
||||
// 延迟回到题单页面
|
||||
goToProblemSetDelayed()
|
||||
}
|
||||
@@ -190,15 +228,25 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 原来的标题是「恭喜你成功提交」:交上去不等于做对。auto-focus 关掉:不预选任何一项 -->
|
||||
<n-modal
|
||||
v-model:show="commentPanel"
|
||||
preset="card"
|
||||
title="恭喜你成功提交,说说你对这道题的感受吧"
|
||||
:title="reviewed ? '大家是这样觉得的' : '做对了!说说你对这道题的感受吧'"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:close-on-esc="false"
|
||||
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
|
||||
v-model:show="commentPanel"
|
||||
:auto-focus="false"
|
||||
style="width: min(600px, calc(100vw - 32px))"
|
||||
>
|
||||
<ProblemReaction @submitted="closeCommentPanel" />
|
||||
<ProblemReaction guard @submitted="onReviewed">
|
||||
<template #after>
|
||||
<n-flex justify="end">
|
||||
<n-button ref="continueButton" type="primary" @click="continueAfterReview">
|
||||
{{ continueAction.label }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</ProblemReaction>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -19,6 +19,7 @@ import PythonErrorExplain from "./PythonErrorExplain.vue"
|
||||
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
|
||||
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
|
||||
import LessonNext from "./LessonNext.vue"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { useDark } from "@vueuse/core"
|
||||
@@ -84,14 +85,11 @@ const sampleCheck = computed(() => {
|
||||
|
||||
/**
|
||||
* 通过之后给「这节课的下一题」。比赛有自己的题目列表;从题单入口进来的,通过后
|
||||
* 1.5 秒会自动跳回题单(SubmitCode 的 goToProblemSetDelayed),这两种都不给
|
||||
* 1.5 秒会自动跳回题单(SubmissionEffects),这两种都不给
|
||||
*/
|
||||
const route = useRoute()
|
||||
const ctx = useProblemPageContext()
|
||||
const showLessonNext = computed(
|
||||
() =>
|
||||
props.submission?.result === SubmissionStatus.accepted &&
|
||||
problemStore.problem?.contestId == null &&
|
||||
!route.params.problemSetId,
|
||||
() => props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
|
||||
)
|
||||
|
||||
const msg = computed(() => {
|
||||
@@ -208,10 +206,8 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
/>
|
||||
</template>
|
||||
</n-alert>
|
||||
<!-- key 用提交 id:同一道题再交一次、又通过了,要重新拉一遍「还剩几道」 -->
|
||||
<LessonNext
|
||||
v-if="showLessonNext && problemStore.problem"
|
||||
:key="submission.id"
|
||||
:problem-display-id="problemStore.problem._id"
|
||||
/>
|
||||
<n-flex
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { RouteLocationNormalizedLoaded } from "vue-router"
|
||||
|
||||
/**
|
||||
* 题目页的三种入口:题库(含课堂)、题单、比赛。同一个 detail.vue 挂在三条路由上。
|
||||
*
|
||||
* 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事
|
||||
* 在题单里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍
|
||||
* (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。
|
||||
*/
|
||||
export type ProblemEntry = "problem" | "problemset" | "contest"
|
||||
|
||||
export type ProblemDrawer = "info" | "reaction" | "submission"
|
||||
|
||||
export const DRAWER_TITLE: Record<ProblemDrawer, string> = {
|
||||
info: "统计",
|
||||
reaction: "点评",
|
||||
submission: "我的提交",
|
||||
}
|
||||
|
||||
interface EntryRules {
|
||||
/** 页签行右侧的抽屉,按显示顺序 */
|
||||
drawers: readonly ProblemDrawer[]
|
||||
/** 标题下的「已解决 / 尝试过」 */
|
||||
statusTag: boolean
|
||||
/** 题面末尾的相似题推荐 */
|
||||
similar: boolean
|
||||
/** 通过之后给「这节课的下一题」 */
|
||||
lessonNext: boolean
|
||||
/** 通过之后 1.5 秒弹强制点评 */
|
||||
reviewAfterAccepted: boolean
|
||||
/** 通过之后 1.5 秒回题单 */
|
||||
backToProblemSet: boolean
|
||||
/** 举手求助 */
|
||||
help: boolean
|
||||
}
|
||||
|
||||
const RULES: Record<ProblemEntry, EntryRules> = {
|
||||
problem: {
|
||||
drawers: ["info", "reaction", "submission"],
|
||||
statusTag: true,
|
||||
similar: true,
|
||||
lessonNext: true,
|
||||
reviewAfterAccepted: true,
|
||||
backToProblemSet: false,
|
||||
help: true,
|
||||
},
|
||||
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
|
||||
problemset: {
|
||||
drawers: [],
|
||||
statusTag: false,
|
||||
// 点进相似题就离开了题单
|
||||
similar: false,
|
||||
lessonNext: false,
|
||||
// 1.5 秒后要跳回题单页,弹了也会被冲掉
|
||||
reviewAfterAccepted: false,
|
||||
backToProblemSet: true,
|
||||
help: true,
|
||||
},
|
||||
contest: {
|
||||
drawers: ["info", "submission"],
|
||||
statusTag: true,
|
||||
// 相似题接口按 displayId 在公开题库里找,比赛题的编号默认是 1/2/3,
|
||||
// 撞上同号公开题时反而会在比赛中把题库列给学生
|
||||
similar: false,
|
||||
lessonNext: false,
|
||||
reviewAfterAccepted: false,
|
||||
backToProblemSet: false,
|
||||
help: false,
|
||||
},
|
||||
}
|
||||
|
||||
export function problemEntryOf(route: RouteLocationNormalizedLoaded): ProblemEntry {
|
||||
if (route.name === "contest problem") return "contest"
|
||||
if (route.name === "problemset problem") return "problemset"
|
||||
return "problem"
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前入口和它的规则。跟着路由算、不在 setup 时取一次:三条路由之间是复用同一个页面的。
|
||||
*/
|
||||
export function useProblemPageContext() {
|
||||
const route = useRoute()
|
||||
return computed(() => {
|
||||
const entry = problemEntryOf(route)
|
||||
return {
|
||||
entry,
|
||||
contestId: entry === "contest" ? String(route.params.contestID ?? "") : "",
|
||||
problemSetId: entry === "problemset" ? String(route.params.problemSetId ?? "") : "",
|
||||
...RULES[entry],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import { useFlowchartStore } from "oj/store/flowchart"
|
||||
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好
|
||||
import SubmissionEffects from "./components/SubmissionEffects.vue"
|
||||
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
||||
import ContextBar from "./components/ContextBar.vue"
|
||||
import {
|
||||
DRAWER_TITLE,
|
||||
useProblemPageContext,
|
||||
type ProblemDrawer as DrawerKey,
|
||||
} from "./composables/problemPageContext"
|
||||
|
||||
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
|
||||
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
|
||||
@@ -19,11 +25,7 @@ import ResultTabLabel from "./components/ResultTabLabel.vue"
|
||||
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
||||
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
|
||||
const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue"))
|
||||
const ProblemInfo = defineAsyncComponent(() => import("./components/ProblemInfo.vue"))
|
||||
const ProblemSubmission = defineAsyncComponent(() => import("./components/ProblemSubmission.vue"))
|
||||
const ProblemReaction = defineAsyncComponent(() => import("./components/ProblemReaction.vue"))
|
||||
const ProblemFlowchart = defineAsyncComponent(() => import("./components/ProblemFlowchart.vue"))
|
||||
const MyFlowchartTab = defineAsyncComponent(() => import("./components/MyFlowchartTab.vue"))
|
||||
const ProblemDrawer = defineAsyncComponent(() => import("./components/ProblemDrawer.vue"))
|
||||
const ResultPane = defineAsyncComponent(() => import("./components/ResultPane.vue"))
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +34,7 @@ interface Props {
|
||||
problemSetId?: string
|
||||
}
|
||||
|
||||
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
|
||||
const { problemID, contestID = "" } = defineProps<Props>()
|
||||
|
||||
const errMsg = ref("无数据")
|
||||
const route = useRoute()
|
||||
@@ -41,39 +43,19 @@ const router = useRouter()
|
||||
const problemStore = useProblemStore()
|
||||
const myFlowchartStore = useMyFlowchartStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const ctx = useProblemPageContext()
|
||||
|
||||
const { isMobile, isDesktop } = useBreakpoints()
|
||||
|
||||
// tab 选项和面板必须用同一个条件。后端在 allowFlowchart 为真时会把 mermaidCode
|
||||
// 置成 null(不能把标准答案下发给正要自己画图的学生),只看 showFlowchart 的话,
|
||||
// 两个开关同时打开就会做出一个「选项存在、面板不存在」的 tab —— URL 里带
|
||||
// ?tab=flowchart 会选中一个渲染不出任何东西的页签。
|
||||
const canShowFlowchart = computed(
|
||||
() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode,
|
||||
/**
|
||||
* 左栏只有「题目 / 结果」两个页签(手机上编辑器也是一个页签,排在中间)。
|
||||
* 原来的六个页签里,流程图并进了「题目」,统计 / 点评 / 我的提交收进了抽屉 ——
|
||||
* 见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 4 节。
|
||||
*/
|
||||
const tabOptions = computed(() =>
|
||||
isMobile.value ? ["content", "editor", "result"] : ["content", "result"],
|
||||
)
|
||||
|
||||
const tabOptions = computed(() => {
|
||||
// 桌面上「结果」紧跟在「题目」后面;手机上编辑器也是一个页签,「结果」排在「代码」后面
|
||||
const options: string[] = ["content"]
|
||||
if (!isMobile.value) options.push("result")
|
||||
if (canShowFlowchart.value) {
|
||||
options.push("flowchart")
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
options.push("editor", "result")
|
||||
}
|
||||
options.push("info")
|
||||
if (!contestID) {
|
||||
options.push("comment")
|
||||
}
|
||||
if (myFlowchartStore.showing) {
|
||||
options.push("my-flowchart")
|
||||
}
|
||||
options.push("submission")
|
||||
return options
|
||||
})
|
||||
|
||||
const currentTab = ref("content")
|
||||
|
||||
watch(
|
||||
@@ -93,11 +75,33 @@ watch(currentTab, (tab) => {
|
||||
})
|
||||
})
|
||||
|
||||
// 交上去、或者语法检查没过:切到「结果」。提交按钮在编辑器那边,页签归这一页管
|
||||
// 「结果」第一次切过去才挂(它带着 DataTable 和 Markdown 渲染),挂上之后切走也不卸载:
|
||||
// 挂着的错误说明在编辑器里标着红,AI 提示也在渲染
|
||||
const resultMounted = ref(false)
|
||||
watch(
|
||||
currentTab,
|
||||
(tab) => {
|
||||
if (tab === "result") resultMounted.value = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const drawer = ref<DrawerKey | null>(null)
|
||||
|
||||
function toggleDrawer(key: DrawerKey) {
|
||||
drawer.value = drawer.value === key ? null : key
|
||||
}
|
||||
|
||||
const drawerMenu = computed<DropdownOption[]>(() =>
|
||||
ctx.value.drawers.map((key) => ({ label: DRAWER_TITLE[key], key })),
|
||||
)
|
||||
|
||||
// 交上去、或者语法检查没过:切到「结果」,抽屉开着就先关掉。提交按钮在编辑器那边,页签归这一页管
|
||||
const submissionStore = useSubmissionStore()
|
||||
watch(
|
||||
() => submissionStore.resultSeq,
|
||||
() => {
|
||||
drawer.value = null
|
||||
currentTab.value = "result"
|
||||
},
|
||||
)
|
||||
@@ -105,13 +109,6 @@ watch(
|
||||
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
||||
const resultTab = () => h(ResultTabLabel)
|
||||
|
||||
watch(
|
||||
() => myFlowchartStore.showing,
|
||||
(showing) => {
|
||||
if (showing) currentTab.value = "my-flowchart"
|
||||
},
|
||||
)
|
||||
|
||||
async function init() {
|
||||
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
|
||||
myFlowchartStore.hide()
|
||||
@@ -169,32 +166,53 @@ onBeforeUnmount(() => {
|
||||
style="height: calc(100vh - 92px)"
|
||||
>
|
||||
<template #1>
|
||||
<n-scrollbar style="height: 100%">
|
||||
<n-tabs v-model:value="currentTab" type="segment">
|
||||
<n-tab-pane name="content" tab="题目">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<!-- 切走也不卸载:挂着的错误说明在编辑器里标着红,AI 提示也在渲染 -->
|
||||
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
|
||||
<ResultPane />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="!contestID" name="comment" tab="题目点评" :disabled="!!problemSetId">
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submission" tab="我的提交" :disabled="!!problemSetId">
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
<div class="left-pane">
|
||||
<ContextBar />
|
||||
<div class="tab-row">
|
||||
<n-tabs
|
||||
v-model:value="currentTab"
|
||||
type="segment"
|
||||
class="page-tabs"
|
||||
@click="drawer = null"
|
||||
>
|
||||
<n-tab name="content">题目</n-tab>
|
||||
<n-tab name="result"><ResultTabLabel /></n-tab>
|
||||
</n-tabs>
|
||||
<n-flex v-if="ctx.drawers.length" :size="6" :wrap="false">
|
||||
<n-button
|
||||
v-for="key in ctx.drawers"
|
||||
:key="key"
|
||||
size="small"
|
||||
:type="drawer === key ? 'primary' : 'default'"
|
||||
:secondary="drawer === key"
|
||||
@click="toggleDrawer(key)"
|
||||
>
|
||||
{{ DRAWER_TITLE[key] }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<!--
|
||||
抽屉挂在页签行下面这一块(position: relative),盖住题面、编辑器不动。
|
||||
页签行露在外面:同一个按钮再点一下就关、点另一个就换,点「题目 / 结果」也会关
|
||||
-->
|
||||
<div id="problem-pane-stack" class="pane-stack">
|
||||
<!--
|
||||
两个页签各滚各的:读题读到底下切去看结果,结果不该也停在底下。
|
||||
v-show 挂在外面这层 div 上 —— 直接挂在 n-scrollbar 上不生效,它自己管根节点的 style
|
||||
-->
|
||||
<div v-show="currentTab === 'content'" class="pane-body">
|
||||
<n-scrollbar content-style="padding-top: 8px">
|
||||
<ProblemContent />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<div v-if="resultMounted" v-show="currentTab === 'result'" class="pane-body">
|
||||
<n-scrollbar content-style="padding-top: 8px">
|
||||
<ResultPane />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<ProblemDrawer v-model="drawer" to="#problem-pane-stack" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<ProblemEditor />
|
||||
@@ -202,32 +220,61 @@ onBeforeUnmount(() => {
|
||||
</n-split>
|
||||
|
||||
<!-- Mobile -->
|
||||
<n-tabs v-else v-model:value="currentTab" type="segment">
|
||||
<template v-else>
|
||||
<ContextBar />
|
||||
<n-tabs v-model:value="currentTab" type="segment">
|
||||
<n-tab-pane name="content" tab="题目">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="editor" tab="代码">
|
||||
<ProblemEditor />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
|
||||
<ResultPane />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="!contestID" name="comment" tab="点评" :disabled="!!problemSetId">
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
<template v-if="drawerMenu.length" #suffix>
|
||||
<n-dropdown trigger="click" :options="drawerMenu" @select="toggleDrawer">
|
||||
<n-button size="small" quaternary aria-label="统计、点评、我的提交"> ⋯ </n-button>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</n-tabs>
|
||||
<ProblemDrawer v-model="drawer" />
|
||||
</template>
|
||||
</template>
|
||||
<n-empty v-else :description="errMsg"></n-empty>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.left-pane {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
flex: none;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
width: 180px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.pane-stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@ import { errorCode } from "utils/api"
|
||||
import { formatISO, getTime, parseISO } from "date-fns"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { ContestStatus, ContestType } from "utils/constants"
|
||||
import { duration } from "utils/functions"
|
||||
import { duration, secondsToDuration } from "utils/functions"
|
||||
import type { Contest, ProblemRow } from "utils/types"
|
||||
import { checkContestPassword, getContest, getContestAccess, getContestProblems } from "../api"
|
||||
|
||||
@@ -40,6 +40,14 @@ export const useContestStore = defineStore("contest", () => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 进行中还剩多久,「38:12」「1:05:00」这种;不在进行中为空串。题目页上下文条用 */
|
||||
const remaining = computed(() => {
|
||||
if (contestStatus.value !== ContestStatus.underway) return ""
|
||||
const end = getTime(parseISO(contest.value!.endTime.toString()))
|
||||
const text = secondsToDuration(Math.max(0, Math.floor((end - now.value) / 1000)))
|
||||
return text.startsWith("0:") ? text.slice(2) : text
|
||||
})
|
||||
|
||||
const isContestAdmin = computed(
|
||||
() =>
|
||||
userStore.isSuperAdmin ||
|
||||
@@ -112,6 +120,7 @@ export const useContestStore = defineStore("contest", () => {
|
||||
problems,
|
||||
isPrivate,
|
||||
countdown,
|
||||
remaining,
|
||||
init,
|
||||
clear,
|
||||
checkPassword,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { getClassActivity } from "oj/api"
|
||||
import type { ClassActivity, ClassActivityProblem } from "utils/types"
|
||||
|
||||
/** 同一节课里来回换题不用每次都重拉;过了这么久再进题目页就重拉一次 */
|
||||
const STALE_MS = 2 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。
|
||||
*
|
||||
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
|
||||
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
|
||||
* (SubmissionEffects)先把这道就地标成做完、再重拉一次。
|
||||
*/
|
||||
export const useLessonStore = defineStore("lesson", () => {
|
||||
const activity = ref<ClassActivity | null>(null)
|
||||
let loadedAt = 0
|
||||
let loading: Promise<void> | null = null
|
||||
|
||||
/** 没拉过或者旧了才拉;force 用在刚通过一道题之后 */
|
||||
function load(force = false) {
|
||||
if (!force && activity.value && Date.now() - loadedAt < STALE_MS) return loading
|
||||
loading = getClassActivity()
|
||||
.then((res) => {
|
||||
activity.value = res
|
||||
loadedAt = Date.now()
|
||||
})
|
||||
// 拉不到就没有课堂条,不影响做题
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
loading = null
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
/** 刚通过:不等重拉,先把这道标成做完,课堂条上的「做完 N/M」马上加一 */
|
||||
function markAccepted(problemDisplayId: string) {
|
||||
const item = find(problemDisplayId)
|
||||
if (item) item.myStatus = "accepted"
|
||||
}
|
||||
|
||||
function find(problemDisplayId: string): ClassActivityProblem | undefined {
|
||||
const id = problemDisplayId.toLowerCase()
|
||||
return activity.value?.problems.find((item) => item.problemDisplayId.toLowerCase() === id)
|
||||
}
|
||||
|
||||
/** 这道题在不在这节课里;不在(学生在做别的)就没有课堂条、也没有「下一题」 */
|
||||
function includes(problemDisplayId: string) {
|
||||
return !!find(problemDisplayId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过这道之后该去哪一道:按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了
|
||||
* 再从头找 —— 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的
|
||||
* myStatus:重拉回来之前,它还是通过之前那一刻的状态。
|
||||
* 这道不在这节课里返回 null;在,但别的都做完了,next 为 null。
|
||||
*/
|
||||
function nextAfter(problemDisplayId: string) {
|
||||
const list = activity.value?.problems ?? []
|
||||
const id = problemDisplayId.toLowerCase()
|
||||
const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === id)
|
||||
if (index < 0) return null
|
||||
const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter(
|
||||
(item) => item.myStatus !== "accepted",
|
||||
)
|
||||
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
|
||||
}
|
||||
|
||||
/** 老师布置的叫「老师布置的题」,推断出来的叫「这节课的题」 */
|
||||
const label = computed(() =>
|
||||
activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题",
|
||||
)
|
||||
|
||||
return { activity, label, load, markAccepted, includes, nextAfter }
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
# OJ2 设计文档:题目详情页重设计
|
||||
|
||||
日期:2026-09-28
|
||||
状态:**设计已定,未实施**。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
|
||||
状态:**设计已定,第 14 节的 1–4 步和第 7 节已实施**。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
|
||||
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
|
||||
**实施时以本文为准**。
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
## 3. 已拍板的决定
|
||||
|
||||
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」留在轮盘里)。
|
||||
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」和其他六项放在一起,不单独挪走)。
|
||||
2. **题单入口隐藏**「我的提交」「本题提交」「统计」「点评」和标题下的「已解决 / 尝试过」标签 ——
|
||||
不让学生看到加入题单之前的提交、抄到答案。
|
||||
3. **「运行例子」先继续用 Judge0**,不另做判题机端点。
|
||||
@@ -161,7 +161,7 @@ B 看得到。
|
||||
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
|
||||
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
|
||||
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
|
||||
- **点评**:轮盘原样,每人只能评一次。
|
||||
- **点评**:和强制弹窗同一个轮盘(第 7 节),每人只能评一次。
|
||||
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
|
||||
|
||||
### 5.6 手机
|
||||
@@ -197,9 +197,18 @@ B 看得到。
|
||||
## 7. 强制点评
|
||||
|
||||
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
|
||||
- 选之前**不显示人数**(圆心只显示悬停的选项名),选完才显示大家怎么选的 —— 避免跟风。
|
||||
- 轮盘 360px(原来 300,每块的字只有 12px 左右)。
|
||||
- 防误触:弹出后 600ms 内不接受点击;不响应 Enter / 空格;不预选任何一块;选完焦点落到「下一题」。
|
||||
- **轮盘重新设计过**(2026-09-28,用户要求保留轮盘的形式):SVG 画的 7 块环形扇区,
|
||||
每块上**直接写着表情和名字**(字号随轮盘缩放,最大 16px),不用先悬停才看得清;悬停的那块
|
||||
染上主色、往外弹出一点,圆心报它的名字。原来的轮盘每块的字只有 12px 左右,而且用了
|
||||
Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
|
||||
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完就地变成**玫瑰图**:每块从里
|
||||
往外填一段、长短按人数,标「N 人」,自己选的那块用主色、弹出、带 ✓ —— 避免跟风。
|
||||
没登录、没做对时轮盘照画、整个淡下去,圆心写原因,不给人数。
|
||||
- 防误触:弹出后 600ms 内轮盘半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
|
||||
click 的 `detail` 是 0),扇区不进 Tab 顺序;弹窗 `auto-focus` 关掉,不预选任何一块。
|
||||
抽屉里的是学生自己点开的,不设防、键盘能选。
|
||||
- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」,
|
||||
否则是这节课的「下一题:题号 题目」,都没有就是「好的」。
|
||||
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
|
||||
先立刻弹出点评,选完再跳。**现在线上可以被跳过**(`SubmitCode.vue` 换题时 `cancelCommentPanel()`)。
|
||||
- 查询或提交点评的接口出错时不能把学生卡住:查询失败就不弹;提交失败留在弹窗里提示「没记上,再点一次」。
|
||||
|
||||
@@ -73,6 +73,19 @@ export const flowchartDetailSchema = z.object({
|
||||
count: z.number().int(),
|
||||
})
|
||||
|
||||
/** 自己在一道题上评完的历次分数,早的在前;hidden = 被题单闸门藏起来的次数 */
|
||||
export const flowchartScoresSchema = z.object({
|
||||
scores: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
score: z.number(),
|
||||
grade: z.string(),
|
||||
createTime: z.string(),
|
||||
}),
|
||||
),
|
||||
hidden: z.number().int(),
|
||||
})
|
||||
|
||||
export const flowchartStatisticsSchema = z.object({
|
||||
totalCount: z.number().int(),
|
||||
avgScore: z.number(),
|
||||
@@ -107,6 +120,7 @@ export type FlowchartListItem = z.infer<typeof flowchartListItemSchema>
|
||||
export type FlowchartList = z.infer<typeof flowchartListSchema>
|
||||
export type FlowchartCurrent = z.infer<typeof flowchartCurrentSchema>
|
||||
export type FlowchartDetail = z.infer<typeof flowchartDetailSchema>
|
||||
export type FlowchartScores = z.infer<typeof flowchartScoresSchema>
|
||||
export type CreateFlowchartResponse = z.infer<typeof createFlowchartResponseSchema>
|
||||
export type CreateFlowchartRequest = z.infer<typeof createFlowchartRequestSchema>
|
||||
|
||||
|
||||
Reference in new issue
Block a user