Compare commits

...
5 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 309ca0ece2 refactor(前端): 全站共用的班级下拉、胶囊配色挪出提交列表目录,编辑器不再偷读题目页的 store
Deploy / deploy (push) Canceled after 0s
- classLabel / groupClassOptions / classSelectOptions 挪到 utils/classes.ts,useTone 挪到
  shared/composables/tone.ts:原来放在 oj/submission/ 下,被前台、后台、shared 引了 50 多处
- 流程图编辑器的草稿键改由题目页传进来(键名不变,学生存着的草稿照样读得到)。原来编辑器
  自己读题目页的 store 和路由拼,后台出题页也用这个组件,那边 store 是空的
- SQL 表名补全改由题目页传表进来。原来补全直接读题目页的 store,自学页的 SQL 代码块会
  补出上一道看过的题的表名

实跑:提交列表、题目页、排名、后台题目页、SQL 题、流程图题打开无报错;SQL 题输入 stu
补出 students;流程图加一步,草稿照旧存在 flowchart-v2-problem-<题号>。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:59:55 +08:00
xuyueandClaude Opus 5.5 465dbfd42f refactor(后端): 权限判断收到一处,路由文件之间不再互相 import
- services/permissions.ts:「超管或创建者」原来在比赛、题单、比赛题目(4 处)、
  services/contest.ts 各抄一遍,「题目全部权限」在题目和标签两个路由各写一份
- services/submission-filters.ts:提交列表的用户名 / 班级 / 题号筛选,原来
  submission.ts、flowchart.ts 从 submission-statistics.ts 这个路由文件里拿
- services/class-activity.ts:课堂做题记录的口径和「正在上课的班」,原来后台
  conf.ts 从 classroom.ts 路由里拿
- services/problem-tags.ts:标签名去重、按名查标签,原来 tag.ts 从 admin/problem.ts 拿

纯搬家。三种身份(超管 / 老师 / 学生)打了 26 个受影响的接口,改前改后状态码和响应体
逐字节一致,包括「不是自己的比赛 / 题单 / 题目报不存在」那几条。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:53:24 +08:00
xuyueandClaude Opus 5.5 5787efe023 refactor(前端): 角色判断改用契约里的白名单,不再在 user store 里逐个列角色
契约 roles.ts 写明 ADMIN_ROLES / TEACHER_ROLES 是全仓唯一的定义处,前端 user store
却用 === 把角色又列了一遍;将来加角色只改契约就会两边对不上。判断结果不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:53:24 +08:00
xuyueandClaude Opus 5.5 fb11e86151 perf(排名): 一个班同时打开排名、班级对抗、按人数排的题目列表时只查一次库
这几样谁来看都一样,原来每次请求现算:全服全部时间的排名本机 16 核就要 250ms,
服务器两核还要判题。services/cache.ts 进程内缓存 10 秒,同时到达的相同请求等同一个结果:
- 排名榜面的原始数据(「我」排第几、前后一名照旧每次现算):250ms → 6ms
- 班级对抗 / 详情 / PK 共用的这学期数据:40ms → 1ms
- 题目列表按做对、做过人数排序的全表人数:45ms → 20ms
40 个人同时打开全服排名(缓存是冷的)一共 342ms。代价是做对之后最多晚 10 秒上榜。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:47:24 +08:00
xuyueandClaude Opus 5.5 307b0725d0 perf(判题): 判题机上同时跑的程序从最多 8 个压到 4 个,挤的时候不再把不超时的判成超时
原来四道闸各管各的(worker 判题 2、api 试运行 2、提交前编译检查 2、worker 里流程图
评分的试运行又 2),api 和 worker 两个进程互相看不见,峰值 8 个程序压在服务器两个核上;
判题机按墙钟判超时,挤到一定程度会冤枉学生。

judge/slots.ts 在 Redis 里按池数名额(每个站点各数各的):
- 后台池(正式判题、诊断重跑、流程图评分)上限 JUDGE_CONCURRENCY
- 交互池(运行例子、自己输入、生成测试点、编译检查)上限 TRIAL_CONCURRENCY
两池不互相借,正式判题排再长也挤不掉学生的「运行例子」。名额是带到期时间的租约,
进程被杀了到期自动作废;Redis 出错直接放行。正式判题的超时从拿到名额才开始算。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:47:24 +08:00
83 changed files with 778 additions and 551 deletions

No files matched your search

+2
View File
@@ -115,6 +115,8 @@ async function evaluateByRunning(
memoryLimit: 256,
cases: cases.map((item) => ({ input: item.input, output: item.expected })),
fullOutput: true,
// 后台评分,别和学生「运行例子」抢交互池的名额
pool: "judge",
})
let results: FlowchartCaseResult[]
+9 -29
View File
@@ -15,6 +15,7 @@ import type { JudgeJobData } from "./job"
import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { parseProblemTemplate, shiftCompileLines } from "./template"
import { withJudgeSlot } from "./slots"
import { runSqlCase } from "./sql"
import {
nativeRuntimeError,
@@ -121,46 +122,22 @@ export async function postJudge(
* 判题机只有 /judge 一个入口,得带一组测试点:给一组空输入,跑的那一下限 1 秒。
* 判题机连不上、超时,一律当编得过 —— 那就和原来一样照常格式化。
*
* 这一下不进判题队列,所以自己排一道:同时最多 COMPILE_CHECK_CONCURRENCY 份在判题机上编,
* 其余等着。服务器只有两核,不排的话一个班 40 人一起交 C++ 能把判题机挤到每人 8 秒超时,
* 还拖慢正式判题。8 秒是连排队带编译的总预算,排不上也当编得过。
* 这一下不进判题队列,但和运行例子共用交互池的名额(judge/slots.ts):服务器只有两核,
* 不排的话一个班 40 人一起交 C++ 能把判题机挤到每人 8 秒超时,还拖慢正式判题。
* 8 秒是连排队带编译的总预算,排不上也当编得过。
*/
const COMPILE_CHECK_CONCURRENCY = 2
let compileChecks = 0
const compileCheckWaiting: (() => void)[] = []
export async function compileFails(language: string, source: string) {
const deadline = AbortSignal.timeout(8000)
if (compileChecks < COMPILE_CHECK_CONCURRENCY) {
compileChecks++
} else {
const admitted = await new Promise<boolean>((resolve) => {
const giveUp = () => {
compileCheckWaiting.splice(compileCheckWaiting.indexOf(wake), 1)
resolve(false)
}
const wake = () => {
deadline.removeEventListener("abort", giveUp)
resolve(true)
}
deadline.addEventListener("abort", giveUp, { once: true })
compileCheckWaiting.push(wake)
})
// 被叫醒时名额是直接转交过来的,compileChecks 不用再加
if (!admitted) return false
}
try {
return await withJudgeSlot("interactive", { holdMs: 30_000, signal: deadline }, async () => {
const response = await postJudge(language, source, 1000, 256, [{ input: "", output: "" }], {
signal: deadline,
})
const body = (await response.json()) as JudgeResponse
return body.err === "CompileError"
})
} catch {
return false
} finally {
const next = compileCheckWaiting.shift()
if (next) next()
else compileChecks--
}
}
@@ -210,6 +187,8 @@ async function requestJudge(
) {
const timeoutMs = judgeTimeoutMs(timeLimit)
try {
// 超时从拿到名额才开始算:排队等名额不算判题机没回应
return await withJudgeSlot("judge", { holdMs: timeoutMs + 30_000 }, async () => {
const response = await postJudge(language, code, timeLimit, memoryLimit, testCase, {
output,
signal: AbortSignal.timeout(timeoutMs),
@@ -217,6 +196,7 @@ async function requestJudge(
const body = await readCapped(response, MAX_RESPONSE_BYTES)
if (body === null) throw new Error("判题机返回的内容太大")
return JSON.parse(body) as JudgeResponse
})
} catch (error) {
// 这句会写进 err_info,学生能看到,别把 "The operation timed out." 原样给他
if (error instanceof DOMException && error.name === "TimeoutError") {
+113
View File
@@ -0,0 +1,113 @@
import { randomUUID } from "node:crypto"
import { config } from "../config"
import { redis } from "../redis"
/**
* 判题沙箱的名额,按池在 Redis 里数。
*
* 原来是四道各管各的闸:worker 判题 2、api 试运行 2、api 提交前编译检查 2、worker 里
* 流程图评分又起一道试运行 2 —— 峰值 8 个程序同时压在服务器那两个核上,判题机按墙钟
* 判超时(CPU 时限的 3 倍),挤到一定程度就会把本来不超时的程序判成超时。
* api 和 worker 是两个进程,进程内的计数管不到对方,所以放 Redis。每个站点有自己的
* Redis 和判题机,按站各数各的正好。
*
* 两个池,名额不互相借:
* - `judge`:后台的 —— 正式判题、诊断重跑、流程图评分的试运行。上限 JUDGE_CONCURRENCY;
* - `interactive`:学生在页面上等着的 —— 运行例子、自己输入、生成测试点、提交前的编译检查。
* 上限 TRIAL_CONCURRENCY。单独留着,正式判题排再长也不会把它挤掉,反过来也一样。
*
* 每个名额是一份租约(有序集合里一个成员,分数是到期时刻):拿着名额的进程被杀了,
* 到期后自动作废,不会永远占着。Redis 出错时直接放行 —— 判题比限流要紧,
* 而 Redis 真挂了,会话也读不出来,整站本来就不可用。
*/
export type JudgePool = "judge" | "interactive"
const LIMITS: Record<JudgePool, number> = {
judge: config.judgeConcurrency,
interactive: config.trialConcurrency,
}
// KEYS[1] = 池的有序集合
// ARGV = now(ms), expireAt(ms), token, limit
const ACQUIRE_SCRIPT = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[4]) then return 0 end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
return 1
`
redis.defineCommand("judgeSlotAcquire", { numberOfKeys: 1, lua: ACQUIRE_SCRIPT })
type SlotRedis = typeof redis & {
judgeSlotAcquire(
key: string,
now: string,
expireAt: string,
token: string,
limit: string,
): Promise<number>
}
function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason)
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(signal!.reason)
}
signal?.addEventListener("abort", onAbort, { once: true })
})
}
/**
* 拿到一个名额再跑 `run`,跑完(不论成败)还回去。
*
* - `holdMs`:租约多长。要比 `run` 最长可能跑的时间长,否则跑到一半名额被别人拿走;
* 调用方都给的是自己的请求超时再加 30 秒。
* - `signal`:等名额等到它 abort 就放弃,抛它的 reason。不给就一直等(正式判题:
* job 在 BullMQ 里本来就是排着的,多等一会儿不要紧)。
*
* 等的方式是隔一会儿问一次(50ms 起翻倍到 500ms)。同时在等的最多也就十来个
* (两个池的上限加上两个进程里各自排着的),这点 Redis 往返不值得为它上阻塞队列。
*/
export async function withJudgeSlot<T>(
pool: JudgePool,
options: { holdMs: number; signal?: AbortSignal },
run: () => Promise<T>,
): Promise<T> {
const key = `judge-slots:${pool}`
const token = randomUUID()
let acquired = false
for (let delay = 50; ; delay = Math.min(500, delay * 2)) {
options.signal?.throwIfAborted()
try {
const now = Date.now()
acquired =
(await (redis as SlotRedis).judgeSlotAcquire(
key,
String(now),
String(now + options.holdMs),
token,
String(LIMITS[pool]),
)) === 1
} catch (error) {
console.warn(`[judge-slots] Redis 不可用,不排队直接判`, error)
return run()
}
if (acquired) break
await sleep(delay, options.signal)
}
try {
return await run()
} finally {
await redis.zrem(key, token).catch((error: unknown) => {
// 还不回去也不要紧,租约到期自己作废
console.warn(`[judge-slots] 归还名额失败`, error)
})
}
}
+25 -2
View File
@@ -10,6 +10,7 @@ import {
type JudgeResponse,
} from "./run"
import { nativeRuntimeError, parsePythonTraceback } from "./runtime-diagnosis"
import { withJudgeSlot, type JudgePool } from "./slots"
import { JudgeStatus, type JudgeStatusValue } from "./status"
import { shiftCompileLines } from "./template"
@@ -25,8 +26,9 @@ import { shiftCompileLines } from "./template"
* - 一个人同时最多两批(「运行例子」和「自己输入」可以同时点),再多直接拒;
* - 等的人太多、或者等得太久,也直接拒,让学生过一会儿再点,别无限挂着。
*
* 队列在进程内:api 只有一个进程,机房和服务器各有各的 api 和判题机,
* 各排各的正好。
* 这道队在进程内,管的是排队上限、每人两批和先来后到;判题机上真正同时跑几批由
* judge/slots.ts 的交互池管(和提交前的编译检查共用,跨 api / worker 两个进程)。
* 机房和服务器各有各的 api、Redis 和判题机,各排各的正好。
*/
const MAX_WAITING = 60
@@ -91,6 +93,8 @@ export async function runTrial(options: {
cases: { input: string; output?: string }[]
/** 管理员生成测试点要完整输出 */
fullOutput: boolean
/** 判题机名额从哪个池拿,默认交互池;流程图评分是后台的,走 judge */
pool?: JudgePool
}): Promise<TrialRunResponse> {
const inFlight = perUser.get(options.userId) ?? 0
if (inFlight >= PER_USER_LIMIT) throw new TrialUserLimitError()
@@ -109,7 +113,26 @@ export async function runTrial(options: {
}
}
/**
* 进程内那道队排到了,还要在判题机的名额池里拿到一个(judge/slots.ts):
* 编译检查和这里共用交互池,流程图评分走后台池。等名额也算在 WAIT_TIMEOUT_MS 里,
* 等不到一样让学生过一会儿再点。
*/
async function judgeTrial(options: Parameters<typeof runTrial>[0]): Promise<TrialRunResponse> {
const wait = AbortSignal.timeout(WAIT_TIMEOUT_MS)
try {
return await withJudgeSlot(
options.pool ?? "interactive",
{ holdMs: JUDGE_TIMEOUT_MS + 30_000, signal: wait },
() => judgeTrialNow(options),
)
} catch (error) {
if (wait.aborted && error === wait.reason) throw new TrialBusyError()
throw error
}
}
async function judgeTrialNow(options: Parameters<typeof runTrial>[0]): Promise<TrialRunResponse> {
const response = await postJudge(
options.language,
options.source,
+1 -1
View File
@@ -26,7 +26,7 @@ import { STUDENT_ROLES } from "@oj2/contract"
import { TEACHER_ROLES } from "../helpers"
import { onlineUserIds } from "../../auth/presence"
import { JudgeStatus } from "../../judge/status"
import { suggestActiveClass } from "../classroom"
import { suggestActiveClass } from "../../services/class-activity"
import { classPrefixCondition, queryInteger } from "../helpers"
export const adminConfRoutes = new Hono<AppEnv>()
+4 -12
View File
@@ -9,22 +9,14 @@ import { and, count, countDistinct, desc, eq, ilike, inArray } from "drizzle-orm
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { contestStatus } from "../../services/contest"
import { queryInteger, sampleUser } from "../helpers"
import { ownsOrSuper } from "../../services/permissions"
export const adminContestRoutes = new Hono<AppEnv>()
/**
* 「只能管自己建的」。对齐旧 `ensure_created_by`:超管放行,其余人只能碰自己创建的。
* 越权时报的是**不存在**而不是无权限 —— 不泄露「有这么个东西但你看不到」。
*/
function ownedBy(user: AuthUser, contest: { createdById: number }) {
return user.adminType === "Super Admin" || contest.createdById === user.id
}
async function serialize(row: {
contest: typeof schema.contest.$inferSelect
user: typeof schema.user.$inferSelect
@@ -132,7 +124,7 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
const [row] = await selectContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row || !ownedBy(c.get("user")!, row.contest)) {
if (!row || !ownsOrSuper(c.get("user")!, row.contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
return success(c, await serialize(row))
@@ -170,7 +162,7 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const parsed = await parseBody(c, updateContestRequestSchema)
if (!parsed.success) return parsed.response
const [existing] = await selectContest(id)
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
if (!existing || !ownsOrSuper(c.get("user")!, existing.contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const error = validatePayload(parsed.data)
@@ -307,7 +299,7 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) =>
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
if (!contest || !ownsOrSuper(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
+15 -76
View File
@@ -30,7 +30,6 @@ import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from "
import { Hono } from "hono"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema, type DbOrTx } from "../../db"
import { failure, parseBody, success } from "../../http"
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
@@ -50,38 +49,15 @@ import { config } from "../../config"
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { getTopReactions } from "../../services/reaction"
import { canEditProblem, canManageAllProblems, ownsOrSuper } from "../../services/permissions"
import { problemUserCounts } from "../../services/problem-progress"
import { findTagsByName, normalizeTagNames } from "../../services/problem-tags"
import { asRecord, queryInteger, sampleUser } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>()
type ProblemRow = typeof schema.problem.$inferSelect
function canManageAll(user: AuthUser) {
return user.adminType === "Super Admin" || user.problemPermission === "All"
}
/**
* 题目归属判断。比赛题看**比赛**的创建者,公开题看题目自己的创建者 ——
* 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。
* 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
*/
export async function canEdit(
user: AuthUser,
problem: Pick<ProblemRow, "contestId" | "createdById">,
) {
if (user.adminType === "Super Admin") return true
if (problem.contestId === null) {
return canManageAll(user) || problem.createdById === user.id
}
const [contest] = await db
.select({ createdById: schema.contest.createdById })
.from(schema.contest)
.where(eq(schema.contest.id, problem.contestId))
.limit(1)
return Boolean(contest && contest.createdById === user.id)
}
async function tagNames(problemId: number) {
return (await tagNamesFor([problemId])).get(problemId) ?? []
}
@@ -103,42 +79,6 @@ async function tagNamesFor(problemIds: number[]) {
return result
}
/**
* 把一批标签名去空格、去重(大小写不敏感),保留每个名字第一次出现时的原始大小写。
* 解析和批量打标签两条路都用它,口径对齐旧 resolve_tags。
*/
export function normalizeTagNames(names: string[]) {
const wanted: string[] = []
const seen = new Set<string>()
for (const raw of names) {
const name = raw.trim()
if (!name || seen.has(name.toLowerCase())) continue
seen.add(name.toLowerCase())
wanted.push(name)
}
return wanted
}
/**
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
* 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。
*/
export async function findTagsByName(tx: DbOrTx, names: string[]) {
const map = new Map<string, number>()
if (names.length === 0) return map
const rows = await tx
.select({ id: schema.problemTag.id, name: schema.problemTag.name })
.from(schema.problemTag)
.where(
inArray(
sql`lower(${schema.problemTag.name})`,
names.map((name) => name.toLowerCase()),
),
)
for (const row of rows) map.set(row.name.toLowerCase(), row.id)
return map
}
/** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */
async function resolveTags(tx: DbOrTx, names: string[]) {
const wanted = normalizeTagNames(names)
@@ -416,7 +356,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
// library=1 是比赛的「从题库中选择」:要列的是 from-public 实际放行的范围 —— 所有可见的
// 公开题,加上自己能改的。只按 created_by 过滤的话,「仅管理自己创建」的老师一道题都看不到
const library = c.req.query("library") === "1"
if (!canManageAll(user)) {
if (!canManageAllProblems(user)) {
const own = eq(schema.problem.createdById, user.id)
filters.push(library ? or(eq(schema.problem.visible, true), own)! : own)
}
@@ -526,7 +466,7 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, row))) {
if (!(await canEditProblem(c.get("user")!, row))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
return success(c, await serialize(row))
@@ -562,7 +502,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
.where(eq(schema.problem.id, id))
.limit(1)
if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) {
if (!(await canEditProblem(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const validated = await validateProblem(parsed.data)
@@ -611,7 +551,7 @@ adminProblemRoutes.delete("/problems/:id", requireProblemPermission, async (c) =
.where(eq(schema.problem.id, id))
.limit(1)
if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) {
if (!(await canEditProblem(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
return deleteProblem(c, id)
@@ -652,7 +592,7 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
if (!contest || !ownsOrSuper(user, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
@@ -719,7 +659,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
if (!contest || !ownsOrSuper(user, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const parsed = await parseBody(c, createProblemRequestSchema)
@@ -754,7 +694,7 @@ adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, a
// 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传,
// 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。
// 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。
if (!(await canEdit(c.get("user")!, problem))) {
if (!(await canEditProblem(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (!problem.contestId || problem.isPublic) {
@@ -804,8 +744,7 @@ adminProblemRoutes.post(
// 「比赛不存在」和「比赛存在但不是你的」必须回同一个码。分开报的话,带一个已知有效的
// problemId 就能靠错误码差异枚举出哪些 contestId 真实存在。全仓其余跨租户路径都是
// 统一码(contest 系列一律 contest-not-found),这里对齐。
const denyContest =
!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)
const denyContest = !contest || !ownsOrSuper(user, contest)
if (denyContest) return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
@@ -813,7 +752,7 @@ adminProblemRoutes.post(
if (problem.contestId !== null) {
return failure(c, 400, "not-a-public-problem", "只能从公开题库添加题目")
}
if (!problem.visible && !(await canEdit(user, problem))) {
if (!problem.visible && !(await canEditProblem(user, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (contestStatus(contest) === "-1")
@@ -863,7 +802,7 @@ adminProblemRoutes.put(
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
if (!contest || !ownsOrSuper(user, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
if (contestStatus(contest) === "-1")
@@ -930,7 +869,7 @@ adminProblemRoutes.get("/problems/:id/test-cases", requireProblemPermission, asy
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
if (!(await canEditProblem(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
try {
@@ -959,7 +898,7 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
if (!(await canEditProblem(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const info = await readInfo(problem.testCaseId)
@@ -985,7 +924,7 @@ adminProblemRoutes.get("/problems/:id/test-case-files", requireProblemPermission
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
if (!(await canEditProblem(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const info = await readInfo(problem.testCaseId)
+2 -6
View File
@@ -21,16 +21,12 @@ import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { recalculateBadge, resyncProgress } from "../../services/problemset"
import { queryInteger, sampleUser } from "../helpers"
import { ownsOrSuper } from "../../services/permissions"
export const adminProblemSetRoutes = new Hono<AppEnv>()
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
/** 对齐旧 ensure_created_by:超管放行,其余人只能碰自己建的。越权报「不存在」 */
function ownedBy(user: AuthUser, row: { createdById: number }) {
return user.adminType === "Super Admin" || row.createdById === user.id
}
/**
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
@@ -42,7 +38,7 @@ async function loadOwned(c: { req: { param(name: string): string } }, user: Auth
.from(schema.problemset)
.where(eq(schema.problemset.id, id))
.limit(1)
return row && ownedBy(user, row) ? row : null
return row && ownsOrSuper(user, row) ? row : null
}
async function serialize(row: typeof schema.problemset.$inferSelect) {
+3 -6
View File
@@ -14,19 +14,16 @@ import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, ne, sql } fr
import { Hono } from "hono"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { completeChat } from "../../services/ai"
import { asRecord, queryInteger } from "../helpers"
import { canEdit, findTagsByName, normalizeTagNames } from "./problem"
import { canEditProblem, canManageAllProblems } from "../../services/permissions"
import { findTagsByName, normalizeTagNames } from "../../services/problem-tags"
export const adminTagRoutes = new Hono<AppEnv>()
/** 能管所有题目:超管,或 problemPermission 为 All */
function canManageAllProblems(user: AuthUser) {
return user.adminType === "Super Admin" || user.problemPermission === "All"
}
// ---------------------------------------------------------------- 标签
@@ -244,7 +241,7 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
const user = c.get("user")!
// 和编辑、删除同一个口径(canEdit):比赛题看比赛的创建者。原来只看题目的创建者,
// 而从题库加进比赛的题保留原作者,老师在自己比赛里点「可见」一律 404
if (!(await canEdit(user, problem))) {
if (!(await canEditProblem(user, problem))) {
return failure(c, 404, "problem-not-found", "题目不存在")
}
await db
+8 -122
View File
@@ -11,7 +11,6 @@ import {
type LastVisit,
type LessonLanguage,
type StatisticInfo,
FLOWCHART_PASS_GRADES,
} from "@oj2/contract"
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -22,7 +21,14 @@ import { getLoginWindow } from "../auth/session"
import { presenceOf } from "../collab/state"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import {
activityRows,
classMemberIds,
FLOWCHART_PASSED,
lessonLanguageFilter,
SOLVED_RESULTS,
suggestActiveClass,
} from "../services/class-activity"
import { accepted } from "../services/learning-stats"
import { calendarDay, dayStart, localTime } from "../time"
@@ -37,9 +43,6 @@ const CLASS_ACTIVITY_MIN_USERS = 5
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
const CLASS_ACTIVITY_LIMIT = 8
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
interface ClassDayGroup {
day: string
problemId: number
@@ -48,93 +51,6 @@ interface ClassDayGroup {
acceptedCount: number
}
/**
* 流程图画到 A / S 算这道题做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
* 等级由分数推出(flowchart/grade.ts),重新评分会先把 grade 清空,所以 status = 2 其实
* 是冗余的,留着是把「评完了」写明。
*/
const FLOWCHART_PASSED = and(
eq(schema.flowchartSubmission.status, 2),
inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]),
)!
/** 这个班正常状态的学生,给下面的查询当 `user_id in (...)` 用 */
function classMemberIds(className: string) {
return db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
}
/**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致:
*
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
*
* 有的题一节课全班都在画流程图、代码提交个位数,只数代码的话这节课在统计里等于没上。
* 两边都按 `user_id in (...)` + `create_time >=` 走各自的 (user_id, create_time) 索引。
*/
/**
* 老师布置时选了语言:代码只数这个语言交的。SQL 题例外 —— 它只收 SQL,按 C 去数的话,
* C 的作业里夹一道 SQL 题,学生怎么交都算不了做完。非 SQL 题交不进 SQL(提交接口按题目的
* 语言拦),所以一律放行 SQL 不会把别的题算宽
*/
function lessonLanguageFilter(language: LessonLanguage | null | undefined) {
return language ? inArray(schema.submission.language, [language, "SQL"]) : undefined
}
function activityRows(options: {
since: string
users?: number[] | ReturnType<typeof classMemberIds>
problemIds?: number[]
/** 老师布置时选了语言:代码只数这个语言交的(见 lessonLanguageFilter),流程图不受影响 */
language?: LessonLanguage | null
}) {
const { since, users, problemIds, language } = options
const code = db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
createTime: schema.submission.createTime,
solved: sql<boolean>`${inArray(schema.submission.result, SOLVED_RESULTS)}`.as("solved"),
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, since),
users ? inArray(schema.submission.userId, users) : undefined,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
lessonLanguageFilter(language),
),
)
const flowchart = db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${FLOWCHART_PASSED}`.as("solved"),
})
.from(schema.flowchartSubmission)
.where(
and(
gte(schema.flowchartSubmission.createTime, since),
users ? inArray(schema.flowchartSubmission.userId, users) : undefined,
problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined,
),
)
return code.unionAll(flowchart).as("activity")
}
/**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
@@ -468,36 +384,6 @@ const RECENT_LESSONS = 5
/** 往回找最近几节课时最多看多远:寒暑假之后开学第一节课,前面几节是上学期的,没意义 */
const RECENT_LESSONS_WINDOW_DAYS = 45
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/**
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
* 的班悄悄留在框里,老师看的整个是别人的班)。
*/
export async function suggestActiveClass() {
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
const activity = activityRows({
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
})
const users = sql<number>`count(distinct ${activity.userId})::int`
const [row] = await db
.select({ className: schema.user.className, users })
.from(activity)
.innerJoin(schema.user, eq(schema.user.id, activity.userId))
.where(
and(
isNotNull(schema.user.className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
return row?.className ?? null
}
/**
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
+1 -1
View File
@@ -26,7 +26,7 @@ import { problemSetLockCutoffs, recordSolvedAndNotify } from "../services/proble
import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency"
import { dayStart } from "../time"
import { unknownDisplayIds } from "./submission-statistics"
import { unknownDisplayIds } from "../services/submission-filters"
import {
classCondition,
isAdminRole,
+2 -1
View File
@@ -36,6 +36,7 @@ import { JudgeStatus } from "../judge/status"
import {
buildProblemProgress,
problemStates,
allProblemUserCounts,
problemUserCounts,
} from "../services/problem-progress"
import {
@@ -180,7 +181,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
// 旧的「画流程图 / 语法检查」排序已经拆成类型筛选,传进来落到默认(最新创建)
const sort = problemListSortSchema.safeParse(c.req.query("sort") ?? "").data ?? ""
const byPeople = sort.endsWith("submission_number") || sort.endsWith("accepted_number")
const allCounts = byPeople ? await problemUserCounts() : null
const allCounts = byPeople ? await allProblemUserCounts() : null
const people = allCounts
? sql`case ${schema.problem.id} ${sql.join(
[...allCounts].map(
+2 -1
View File
@@ -28,6 +28,7 @@ import {
loadEntrants,
PK_MAX_CLASSES,
pkPartner,
rankEntrants,
standings,
WEEK_MS,
type Standing,
@@ -107,7 +108,7 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
const isTeacher = isTeacherOrAbove(user)
const [entrants, mine, hiddenUsers] = await Promise.all([
loadEntrants(audienceWhere(scope.data, group), start),
rankEntrants(scope.data, group, start),
user && !isTeacher
? db
.select({ rankHiddenAt: schema.user.rankHiddenAt })
+2 -134
View File
@@ -15,7 +15,7 @@ import {
type SubmissionStatisticsGrid,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, 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, requireTeacher } from "../auth/middleware"
@@ -25,14 +25,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { dayStart, localTime } from "../time"
import {
classCondition,
isAdminRole,
parseDisplayIds,
rounded,
scopedUsers,
stripClassPrefix,
} from "./helpers"
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -191,131 +184,6 @@ async function findPublicProblemsByDisplayIds(displayIds: string[]) {
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* 两条提交列表的用户名筛选。**两边都要匹配**:
*
* - `user_id in (改过名的当前用户名匹配到的账号)` —— 老师用现在的班级前缀查
* `ks248`,要能查出这个人改名之前交的那些(生产快照:比赛提交里有 685 条
* 挂在旧名字下);
* - `submission.username ilike` —— 已删号的学生在 `user` 表里没有行,只剩提交里
* 冻结的那份名字;顺带也让「按记得的旧名字查」还查得到。
*
* 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来
* 就不在花名册里);这两条是公开列表,不该因为改名或删号少给记录,所以取并集。
*
* 账号那一支**先查出 id 再拼成字面列表**,不写成 `user_id in (子查询)`:子查询夹在 OR
* 里会被做成 hashed SubPlan,整条 OR 就不可索引,加了 trigram 索引照样全表扫。拆开之后
* 两支各走各的索引(submission_public_metrics_idx + submission_public_username_trgm_idx),
* 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms,
* 和原来持平。
*/
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里),
* 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件
*/
export async function exactUsernameFilter(username: string) {
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return row ? eq(schema.submission.userId, row.id) : sql`false`
}
/**
* 按班级筛:`user.class_name` 精确匹配。原来列表只能拿用户名前缀(`ks253`)当班级,
* 那是「包含」匹配 —— ks253 会顺带捞进 ks2531 这类撞前缀的号,也要老师记得前缀怎么拼。
* 查无此班留恒假条件
*/
export async function classNameFilter(className: string) {
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(classCondition(className))
return users.length
? inArray(
schema.submission.userId,
users.map((row) => row.id),
)
: sql`false`
}
/**
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
* 可以一次填几道(`1021,1022`),**任一道**对上就算。
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
*
* 公开列表只认公开题、比赛列表只认本场的题:题号只在这个范围内唯一(比赛题的 `_id`
* 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
*/
/**
* 题号框里查不到的那几个(范围同 problemFilter:公开列表只认公开题,比赛列表只认本场的题)。
* 列表把它们回给前端,打错一个字的时候老师能看到提示
*/
export async function unknownDisplayIds(displayId: string, contestId: number | null) {
const ids = parseDisplayIds(displayId)
if (!ids.length) return []
const rows = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(
sql`lower(${schema.problem.displayId})`,
ids.map((id) => id.toLowerCase()),
),
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
return ids.filter((id) => !found.has(id.toLowerCase()))
}
export async function problemFilter(displayId: string, contestId: number | null) {
// 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
if (!lowered.length) return sql`false`
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
/**
* 两个统计接口共用的范围:时间窗 + 题号。**用户名不在里面** —— 统计那边是
* ilike 模糊匹配(填 ks251 要匹配整个班),明细那边必须精确到人,口径不同。
+2 -2
View File
@@ -38,10 +38,10 @@ import {
classNameFilter,
exactUsernameFilter,
problemFilter,
submissionStatisticsRoutes,
unknownDisplayIds,
usernameFilter,
} from "./submission-statistics"
} from "../services/submission-filters"
import { submissionStatisticsRoutes } from "./submission-statistics"
export const submissionRoutes = new Hono<ContestEnv>()
+34
View File
@@ -0,0 +1,34 @@
/**
* 进程内的短缓存,同时到达的相同请求只查一次库。
*
* 给「谁来看都一样」的聚合用:排名的那群人做对了哪些题、全服班级对抗、全部题目的
* 做对人数。这些在本机 16 核上就要 40–250ms,服务器只有两核、还要判题,而上课时一个班
* 几十个人会在同一分钟打开同一张榜。缓存的是原始数据,「我」排第几、前后一名这些
* 因人而异的部分照旧每次现算。
*
* 放进程内不放 Redis:值是几百 KB 的对象,序列化来回比重算省不了多少;api 只有一个
* 进程,两个站点各算各的也没关系。不做失效,等它过期 —— 调用方选的 TTL 都很短。
*
* 缓存的是 Promise 本身:第一个请求还在查的时候,后到的直接等同一个结果。
* 查失败了马上丢掉,下一个请求重新查,不把错误缓存住。
*/
const entries = new Map<string, { value: Promise<unknown>; expiresAt: number }>()
/** 键的种类有限(几个班 × 几个时间段),超过这么多才顺手扫一遍过期的 */
const SWEEP_ABOVE = 200
export function cached<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
const now = Date.now()
const hit = entries.get(key)
if (hit && hit.expiresAt > now) return hit.value as Promise<T>
if (entries.size > SWEEP_ABOVE) {
for (const [k, entry] of entries) if (entry.expiresAt <= now) entries.delete(k)
}
const value = load()
entries.set(key, { value, expiresAt: now + ttlMs })
value.catch(() => {
if (entries.get(key)?.value === value) entries.delete(key)
})
return value
}
+130
View File
@@ -0,0 +1,130 @@
import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LessonLanguage } from "@oj2/contract"
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus } from "../judge/status"
/**
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班,
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
*/
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
export const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 流程图画到 A / S 算这道题做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
* 等级由分数推出(flowchart/grade.ts),重新评分会先把 grade 清空,所以 status = 2 其实
* 是冗余的,留着是把「评完了」写明。
*/
export const FLOWCHART_PASSED = and(
eq(schema.flowchartSubmission.status, 2),
inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]),
)!
/** 这个班正常状态的学生,给下面的查询当 `user_id in (...)` 用 */
export function classMemberIds(className: string) {
return db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
}
/**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致:
*
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
*
* 有的题一节课全班都在画流程图、代码提交个位数,只数代码的话这节课在统计里等于没上。
* 两边都按 `user_id in (...)` + `create_time >=` 走各自的 (user_id, create_time) 索引。
*/
/**
* 老师布置时选了语言:代码只数这个语言交的。SQL 题例外 —— 它只收 SQL,按 C 去数的话,
* C 的作业里夹一道 SQL 题,学生怎么交都算不了做完。非 SQL 题交不进 SQL(提交接口按题目的
* 语言拦),所以一律放行 SQL 不会把别的题算宽
*/
export function lessonLanguageFilter(language: LessonLanguage | null | undefined) {
return language ? inArray(schema.submission.language, [language, "SQL"]) : undefined
}
export function activityRows(options: {
since: string
users?: number[] | ReturnType<typeof classMemberIds>
problemIds?: number[]
/** 老师布置时选了语言:代码只数这个语言交的(见 lessonLanguageFilter),流程图不受影响 */
language?: LessonLanguage | null
}) {
const { since, users, problemIds, language } = options
const code = db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
createTime: schema.submission.createTime,
solved: sql<boolean>`${inArray(schema.submission.result, SOLVED_RESULTS)}`.as("solved"),
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, since),
users ? inArray(schema.submission.userId, users) : undefined,
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
lessonLanguageFilter(language),
),
)
const flowchart = db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${FLOWCHART_PASSED}`.as("solved"),
})
.from(schema.flowchartSubmission)
.where(
and(
gte(schema.flowchartSubmission.createTime, since),
users ? inArray(schema.flowchartSubmission.userId, users) : undefined,
problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined,
),
)
return code.unionAll(flowchart).as("activity")
}
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/**
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
* 的班悄悄留在框里,老师看的整个是别人的班)。
*/
export async function suggestActiveClass() {
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
const activity = activityRows({
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
})
const users = sql<number>`count(distinct ${activity.userId})::int`
const [row] = await db
.select({ className: schema.user.className, users })
.from(activity)
.innerJoin(schema.user, eq(schema.user.id, activity.userId))
.where(
and(
isNotNull(schema.user.className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
return row?.className ?? null
}
+2 -1
View File
@@ -17,6 +17,7 @@ import type { AuthUser } from "../auth/session"
import { getContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { failure } from "../http"
import { ownsOrSuper } from "./permissions"
export type ContestRow = typeof schema.contest.$inferSelect
@@ -39,7 +40,7 @@ export function contestStatus(contest: ContestRow) {
}
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
return Boolean(user && ownsOrSuper(user, contest))
}
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
+44
View File
@@ -0,0 +1,44 @@
import { eq } from "drizzle-orm"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
/**
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、
* 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里
* 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。
*/
/**
* 「只能管自己建的」:超管放行,其余人只能碰自己创建的。对齐旧后端 `ensure_created_by`。
* 越权时调用方一律报**不存在**而不是无权限 —— 不泄露「有这么个东西但你看不到」。
*/
export function ownsOrSuper(user: AuthUser, row: { createdById: number }) {
return user.adminType === "Super Admin" || row.createdById === user.id
}
/** 公开题的「全部」权限:超管,或题目权限是 All */
export function canManageAllProblems(user: AuthUser) {
return user.adminType === "Super Admin" || user.problemPermission === "All"
}
/**
* 题目归属判断。比赛题看**比赛**的创建者,公开题看题目自己的创建者 ——
* 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。
* 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
*/
export async function canEditProblem(
user: AuthUser,
problem: { contestId: number | null; createdById: number },
) {
if (user.adminType === "Super Admin") return true
if (problem.contestId === null) {
return canManageAllProblems(user) || problem.createdById === user.id
}
const [contest] = await db
.select({ createdById: schema.contest.createdById })
.from(schema.contest)
.where(eq(schema.contest.id, problem.contestId))
.limit(1)
return Boolean(contest && contest.createdById === user.id)
}
@@ -6,6 +6,7 @@ import { isAccepted, UNJUDGED_RESULTS } from "../judge/status"
import { asRecord } from "../routes/helpers"
import { calendarDay, dayNumber, dayText, weekStart } from "../time"
import { accepted } from "./learning-stats"
import { cached } from "./cache"
/**
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
@@ -49,6 +50,14 @@ export async function problemStates(userId: number) {
return { solved, tried }
}
/**
* 全部题的做对 / 交过人数,题目列表「按人数排」用。全表聚合,谁来排都一样,缓存 10 秒
* (见 services/cache.ts),列表上的人数最多晚 10 秒。
*/
export function allProblemUserCounts() {
return cached("problem-user-counts", 10_000, () => problemUserCounts())
}
/**
* 每道题做对 / 交过的人数,和题目页「统计」页签(`problem-stats.ts`)同一个口径:
* 代码提交,不算还在判的,AST_CHECK_FAILED 也是做对。不传 ids 就是全部题(排序用,全表约 85ms)。
+39
View File
@@ -0,0 +1,39 @@
import { inArray, sql } from "drizzle-orm"
import { schema, type DbOrTx } from "../db"
/**
* 把一批标签名去空格、去重(大小写不敏感),保留每个名字第一次出现时的原始大小写。
* 解析和批量打标签两条路都用它,口径对齐旧 resolve_tags。
*/
export function normalizeTagNames(names: string[]) {
const wanted: string[] = []
const seen = new Set<string>()
for (const raw of names) {
const name = raw.trim()
if (!name || seen.has(name.toLowerCase())) continue
seen.add(name.toLowerCase())
wanted.push(name)
}
return wanted
}
/**
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
* 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。
*/
export async function findTagsByName(tx: DbOrTx, names: string[]) {
const map = new Map<string, number>()
if (names.length === 0) return map
const rows = await tx
.select({ id: schema.problemTag.id, name: schema.problemTag.name })
.from(schema.problemTag)
.where(
inArray(
sql`lower(${schema.problemTag.name})`,
names.map((name) => name.toLowerCase()),
),
)
for (const row of rows) map.set(row.name.toLowerCase(), row.id)
return map
}
+19 -2
View File
@@ -29,6 +29,7 @@ import { db, schema } from "../db"
import { JudgeStatus } from "../judge/status"
import { sampleUser, rounded } from "../routes/helpers"
import { termStart, weekStart } from "../time"
import { cached } from "./cache"
/**
* 排名页和班级详情、班级 AI 分析共用的口径(routes/ranking.ts、routes/ai.ts):
@@ -173,7 +174,20 @@ const PLATEAU_MIN = 5
/** 班级详情的每周人均列最近几周 */
const DETAIL_WEEKS = 6
/** 有班级的学生,这学期的做对时刻 —— 班级对抗和班级详情都从这一份算 */
/**
* 排名、班级对抗这些「谁来看都一样」的数据缓存多久(services/cache.ts)。10 秒:
* 一个班同时打开时只查一次;学生做对一道题马上去看排名,最多晚 10 秒看到。
*/
const SHARED_TTL = 10_000
/** 排名榜面那群人的做对时刻:同一个范围、同一个时间段谁来看都一样,「我」的部分由调用方现算 */
export function rankEntrants(scope: RankScope, group: string | null, since: string | null) {
return cached(`rank-entrants:${scope}:${group}:${since}`, SHARED_TTL, () =>
loadEntrants(audienceWhere(scope, group), since),
)
}
/** 有班级的学生,这学期的做对时刻 —— 班级对抗、班级详情、班级 PK 都从这一份算 */
async function classedEntrants() {
const term = termStart()
const where = and(
@@ -181,7 +195,10 @@ async function classedEntrants() {
isNotNull(schema.user.className),
ne(schema.user.className, ""),
)!
return { term, entrants: await loadEntrants(where, term) }
return {
term,
entrants: await cached(`classed-entrants:${term}`, SHARED_TTL, () => loadEntrants(where, term)),
}
}
function battleFrom(entrants: Entrant[]) {
+135
View File
@@ -0,0 +1,135 @@
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { classCondition, parseDisplayIds } from "../routes/helpers"
/**
* 两条提交列表(公开列表、比赛里的列表)的筛选条件:用户名、班级、题号。
* 原来写在 routes/submission-statistics.ts 里,submission.ts 和 flowchart.ts 都要从
* 另一个路由文件 import —— 挪到 services 来。
*/
/**
* 两条提交列表的用户名筛选。**两边都要匹配**:
*
* - `user_id in (改过名的当前用户名匹配到的账号)` —— 老师用现在的班级前缀查
* `ks248`,要能查出这个人改名之前交的那些(生产快照:比赛提交里有 685 条
* 挂在旧名字下);
* - `submission.username ilike` —— 已删号的学生在 `user` 表里没有行,只剩提交里
* 冻结的那份名字;顺带也让「按记得的旧名字查」还查得到。
*
* 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来
* 就不在花名册里);这两条是公开列表,不该因为改名或删号少给记录,所以取并集。
*
* 账号那一支**先查出 id 再拼成字面列表**,不写成 `user_id in (子查询)`:子查询夹在 OR
* 里会被做成 hashed SubPlan,整条 OR 就不可索引,加了 trigram 索引照样全表扫。拆开之后
* 两支各走各的索引(submission_public_metrics_idx + submission_public_username_trgm_idx),
* 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms,
* 和原来持平。
*/
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里),
* 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件
*/
export async function exactUsernameFilter(username: string) {
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return row ? eq(schema.submission.userId, row.id) : sql`false`
}
/**
* 按班级筛:`user.class_name` 精确匹配。原来列表只能拿用户名前缀(`ks253`)当班级,
* 那是「包含」匹配 —— ks253 会顺带捞进 ks2531 这类撞前缀的号,也要老师记得前缀怎么拼。
* 查无此班留恒假条件
*/
export async function classNameFilter(className: string) {
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(classCondition(className))
return users.length
? inArray(
schema.submission.userId,
users.map((row) => row.id),
)
: sql`false`
}
/**
* 题号框里查不到的那几个(范围同 problemFilter:公开列表只认公开题,比赛列表只认本场的题)。
* 列表把它们回给前端,打错一个字的时候老师能看到提示
*/
export async function unknownDisplayIds(displayId: string, contestId: number | null) {
const ids = parseDisplayIds(displayId)
if (!ids.length) return []
const rows = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(
sql`lower(${schema.problem.displayId})`,
ids.map((id) => id.toLowerCase()),
),
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
return ids.filter((id) => !found.has(id.toLowerCase()))
}
/**
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
* 可以一次填几道(`1021,1022`),**任一道**对上就算。
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
*
* 公开列表只认公开题、比赛列表只认本场的题:题号只在这个范围内唯一(比赛题的 `_id`
* 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
*/
export async function problemFilter(displayId: string, contestId: number | null) {
// 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
if (!lowered.length) return sql`false`
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
+1 -1
View File
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue"
import PageHeader from "admin/components/PageHeader.vue"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import Pagination from "shared/components/Pagination.vue"
+1 -1
View File
@@ -6,7 +6,7 @@ import { getLearnBoard, getLearnExercises } from "admin/api"
import { parseTime, readableDuration } from "utils/functions"
import { useConfigStore } from "shared/store/config"
import UserName from "shared/components/UserName.vue"
import { groupClassOptions } from "oj/submission/utils"
import { groupClassOptions } from "utils/classes"
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
import "oj/learn/learn.css"
import ExerciseAttempts from "./ExerciseAttempts.vue"
+1 -1
View File
@@ -7,7 +7,7 @@ import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
import { useConfigStore } from "shared/store/config"
import UserName from "shared/components/UserName.vue"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "utils/classes"
import { periodRange, type Period } from "oj/statistics/period"
import { resultName, useResultColor } from "oj/statistics/results"
import { useBackClose } from "shared/composables/backClose"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { errorMessage } from "utils/api"
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants"
import { copyToClipboard, parseTime } from "utils/functions"
+2 -2
View File
@@ -4,9 +4,9 @@ import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } fro
import { useThemeVars } from "naive-ui"
import { Bar } from "vue-chartjs"
import { useChartTheme } from "shared/composables/chartTheme"
import { rgba } from "oj/submission/composables/tone"
import { rgba } from "shared/composables/tone"
import PageHeader from "admin/components/PageHeader.vue"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "utils/classes"
import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { SubmissionStatus } from "utils/constants"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { downloadAccountsCsv } from "./accountsCsv"
import PageHeader from "admin/components/PageHeader.vue"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { errorMessage } from "utils/api"
import {
+1 -1
View File
@@ -15,7 +15,7 @@ import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router"
import { NO_CLASS, STUDENT_ROLES } from "@oj2/contract"
import { downloadAccountsCsv } from "./accountsCsv"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "utils/classes"
import { useConfigStore } from "shared/store/config"
import { useBreakpoints } from "shared/composables/breakpoints"
+1 -1
View File
@@ -11,7 +11,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import UserName from "shared/components/UserName.vue"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { parseTime } from "utils/functions"
import { getLearningReport } from "../api"
import AiSummary from "./components/AiSummary.vue"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { LearningDay } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import ProblemChip from "./ProblemChip.vue"
import { dayLabel, isDone } from "../utils"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { parseTime } from "utils/functions"
import { needToTie, pkColor, plateau, shortLabels, tint } from "../utils"
import PkHist from "./PkHist.vue"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ClassPk } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { useRankPalette } from "oj/rank/palette"
import { needToTie, pkColor, shortLabels, tint } from "../utils"
import PkRace from "./PkRace.vue"
+1 -1
View File
@@ -2,7 +2,7 @@
import type { ClassBattleItem, ClassPk, ClassPkPeriod } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { getClassBattle, getClassPk } from "oj/api"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "utils/classes"
import { useAIStream } from "shared/composables/aiStream"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
/**
* 每个班一种颜色,按选班的顺序给。蓝 / 橙打头:两个班对决时最常见,这两个色盲也分得开;
+2 -2
View File
@@ -2,8 +2,8 @@
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getClassBoard, setClassLesson } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { groupClassOptions } from "oj/submission/utils"
import { useTone } from "shared/composables/tone"
import { groupClassOptions } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useTeacherHelp } from "shared/composables/teacherHelp"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { isExamTag } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
/**
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ContestScoreRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { secondsToDuration } from "utils/functions"
import UserName from "shared/components/UserName.vue"
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { ContestStatus } from "utils/constants"
import { parseTime, secondsToDuration } from "utils/functions"
import NotFound from "shared/components/NotFound.vue"
+1 -1
View File
@@ -2,7 +2,7 @@
import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getContestList } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { contestLength, parseTime, secondsToDuration } from "utils/functions"
import type { Contest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue"
+2 -2
View File
@@ -5,8 +5,8 @@ import { updateACMHelperChecked } from "admin/api"
import { getContestClassView, getSubmission, getSubmissions } from "oj/api"
import { useContestStore } from "oj/store/contest"
import UserName from "shared/components/UserName.vue"
import { useTone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import { useTone } from "shared/composables/tone"
import { classLabel } from "utils/classes"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { ContestStatus, LANGUAGE_FORMAT_VALUE } from "utils/constants"
import { parseTime, secondsToDuration } from "utils/functions"
+1 -1
View File
@@ -2,7 +2,7 @@
import type { ContestScoreRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useContestStore } from "oj/store/contest"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants"
import { parseTime, secondsToDuration } from "utils/functions"
+2 -2
View File
@@ -2,11 +2,11 @@
import type { ContestScoreCell, ContestScoreRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useContestStore } from "oj/store/contest"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants"
import { secondsToDuration } from "utils/functions"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useVisibleInterval } from "shared/composables/visibleInterval"
+1 -1
View File
@@ -3,7 +3,7 @@ import type { ClassToday } from "@oj2/contract"
import type { AnnouncementListItem } from "utils/types"
import { useThemeVars } from "naive-ui"
import { getAnnouncementList, getClassToday } from "oj/api"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { useUserStore } from "shared/store/user"
import { parseTime, zonedParts, zonedYear } from "utils/functions"
@@ -120,6 +120,19 @@ onMounted(loadCode)
watch(() => problem.value?._id, loadCode)
/**
* 流程图草稿:每道题一份;题单入口单独一份 —— 加入题单之前在题库里画到 A 的那张,不能一进
* 题单就摆在画布上(设计文档第 11 节 3,和代码草稿同理)。
* 键名带 v2:2026-10 之前的草稿是 vue-flow 的格式,读不了,也不去覆盖它
*/
const flowchartDraftKey = computed(() => {
const id = problem.value?._id
if (!id) return "flowchart-v2"
const base = `flowchart-v2-problem-${id}`
const problemSetId = route.params.problemSetId
return problemSetId ? `${base}-problemset-${problemSetId}` : base
})
/**
* 存本地草稿。**协作中的教师不存** —— 那会儿编辑器里是学生的代码,存下去就把
* 老师自己在这道题上的草稿盖掉了(他可能正开着自己的解法)。学生照存,那是他本人的。
@@ -219,6 +232,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
:height="editorHeight"
:draft-key="flowchartDraftKey"
/>
<SyncCodeEditor
v-else
@@ -226,6 +240,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
:language="codeStore.code.language"
:problem-id="problem!._id"
:collab="ctx.collab"
:sql-tables="problem?.sqlDisplay?.tables"
:height="editorHeight"
:extra-extensions="editorExtensions"
:font-size="isDesktop ? 20 : 15"
@@ -5,8 +5,9 @@ import { storeToRefs } from "pinia"
import { getProblemStats } from "oj/api"
import { useContestStore } from "oj/store/contest"
import { useProblemStore } from "oj/store/problem"
import { useTone, type Tone } from "oj/submission/composables/tone"
import { classLabel, submissionDayText } from "oj/submission/utils"
import { useTone, type Tone } from "shared/composables/tone"
import { classLabel } from "utils/classes"
import { submissionDayText } from "oj/submission/utils"
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
import { getTagColor, parseTime } from "utils/functions"
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import type { ProblemProgress } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
/**
* 题目列表顶上那行的左半边:先说做到了什么(设计稿「题目列表重设计 · 改版」)。
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue"
import type { Tag, TagCategory } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
/**
* 题目列表左栏:知识点 / 主题,带自己的进度(设计稿「题目列表重设计 · 改版」)。
+1 -1
View File
@@ -5,7 +5,7 @@ import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getAuthors, getProblemList, getProblemProgress, getRandomProblem } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import type { ProblemRow } from "utils/types"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import { badgeCondition, badgeShortfall, ladder, nextBadge } from "../badges"
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetClassView } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { useTone } from "shared/composables/tone"
import { classLabel, groupClassOptions } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions"
import type { ProblemSetClassView } from "utils/types"
+1 -1
View File
@@ -2,7 +2,7 @@
import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import { errorMessage } from "utils/api"
import { parseTime } from "utils/functions"
import type { ProblemSet, ProblemSetProblem } from "utils/types"
+1 -1
View File
@@ -4,7 +4,7 @@ import AdminLink from "shared/components/AdminLink.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { getProblemSetList } from "oj/api"
import { rgba, useTone } from "oj/submission/composables/tone"
import { rgba, useTone } from "shared/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import { badgeCondition, ladder, nextBadge } from "./badges"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ClassBattleItem } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import { useRankPalette } from "../palette"
/**
@@ -2,7 +2,7 @@
import type { ClassDetail } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { getClassDetail } from "oj/api"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useAIStream } from "shared/composables/aiStream"
import { useBackClose } from "shared/composables/backClose"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useDark } from "@vueuse/core"
import { rgba } from "oj/submission/composables/tone"
import { rgba } from "shared/composables/tone"
import { USERNAME_CLASS_RE } from "utils/constants"
/**
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
} from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { clearMood, getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from "oj/api"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useConfigStore } from "shared/store/config"
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RankPeriod, RankRow, RankScope } from "@oj2/contract"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
export const SCOPE_OPTIONS: { value: RankScope; label: string }[] = [
{ value: "class", label: "本班" },
@@ -14,7 +14,7 @@ import { getSubmission } from "oj/api"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "oj/submission/composables/tone"
import { useTone, type Tone } from "shared/composables/tone"
/**
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
@@ -2,8 +2,9 @@
import { useThemeVars } from "naive-ui"
import { NO_CLASS, type SubmissionStatisticsGrid } from "@oj2/contract"
import { SubmissionStatus } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import { classLabel, submissionDayText } from "oj/submission/utils"
import { useTone } from "shared/composables/tone"
import { classLabel } from "utils/classes"
import { submissionDayText } from "oj/submission/utils"
/**
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
@@ -5,7 +5,7 @@ import type { SubmissionStatistics, SubmissionStatisticsGrid } from "@oj2/contra
import { SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import StatusPill from "oj/submission/components/StatusPill.vue"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import UserName from "shared/components/UserName.vue"
import AttemptSquares from "./AttemptSquares.vue"
@@ -18,7 +18,7 @@ import { Bar, Doughnut, Radar } from "vue-chartjs"
import type { FlowchartStatistics } from "@oj2/contract"
import { useChartTheme } from "shared/composables/chartTheme"
import { FLOWCHART_CRITERIA_ORDER } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import { useTone } from "shared/composables/tone"
import RingChart from "./RingChart.vue"
import StatItem from "./StatItem.vue"
import UserName from "shared/components/UserName.vue"
@@ -3,8 +3,8 @@ import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { SubmissionLessons } from "@oj2/contract"
import { parseTime, zonedParts } from "utils/functions"
import { useTone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import { useTone } from "shared/composables/tone"
import { classLabel } from "utils/classes"
import { resultName, useResultColor } from "../results"
type Lesson = SubmissionLessons["lessons"][number]
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone, type Tone } from "oj/submission/composables/tone"
import { useTone, type Tone } from "shared/composables/tone"
/** 数字行的一格:小字标签 + 大号数字 + 单位 */
const props = defineProps<{
@@ -16,8 +16,8 @@ import {
} from "oj/api"
import { useConfigStore } from "shared/store/config"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { useTone } from "oj/submission/composables/tone"
import { classSelectOptions } from "oj/submission/utils"
import { useTone } from "shared/composables/tone"
import { classSelectOptions } from "utils/classes"
import { DEFAULT_PERIOD, PERIOD_OPTIONS, dayStartOfDate, periodRange, type Period } from "../period"
import { resultName, useResultColor } from "../results"
import ByClass from "./ByClass.vue"
+1 -1
View File
@@ -1,7 +1,7 @@
import { useThemeVars } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "oj/submission/composables/tone"
import { useTone, type Tone } from "shared/composables/tone"
export function resultName(result: number) {
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
@@ -6,7 +6,7 @@ import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
import type { FlowchartSubmission, FlowchartSubmissionListItem } from "utils/types"
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone } from "../composables/tone"
import { useTone } from "shared/composables/tone"
import { submissionTimeText } from "../utils"
import FlowchartState from "./FlowchartState.vue"
import UserName from "shared/components/UserName.vue"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
import { useTone, type Tone } from "shared/composables/tone"
/**
* 流程图提交在列表里的那个胶囊:评完的直接是「A 级」,没评完的是
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
import { useTone, type Tone } from "shared/composables/tone"
const props = defineProps<{
result: SUBMISSION_RESULT
@@ -14,8 +14,9 @@ import { submissionCaseResults } from "utils/functions"
import type { Submission, SubmissionListItem } from "utils/types"
import { useCopySubmission } from "../composables/copySubmission"
import { useSubmissionExplain } from "../composables/explain"
import { useTone } from "../composables/tone"
import { classLabel, submissionTimeText } from "../utils"
import { useTone } from "shared/composables/tone"
import { classLabel } from "utils/classes"
import { submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import ExplainNote from "./ExplainNote.vue"
import StatusPill from "./StatusPill.vue"
@@ -19,8 +19,9 @@ import { parseTime, submissionCaseResults } from "utils/functions"
import type { Submission } from "utils/types"
import { useCopySubmission } from "../composables/copySubmission"
import { useSubmissionExplain } from "../composables/explain"
import { useTone } from "../composables/tone"
import { classLabel, submissionTimeText } from "../utils"
import { useTone } from "shared/composables/tone"
import { classLabel } from "utils/classes"
import { submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import ExplainNote from "./ExplainNote.vue"
import StatusPill from "./StatusPill.vue"
@@ -3,7 +3,7 @@ import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { SubmissionStatus } from "utils/constants"
import type { Submission } from "utils/types"
import { useTone } from "./tone"
import { useTone } from "shared/composables/tone"
export interface SubmissionExplain {
/** 错在第几行(编辑器里那一行标红),说不出来的为 null */
+3 -2
View File
@@ -31,8 +31,9 @@ import { FlowchartSubmissionStatus } from "utils/types"
import FlowchartState from "./components/FlowchartState.vue"
import StatusPill from "./components/StatusPill.vue"
import UserName from "shared/components/UserName.vue"
import { useTone } from "./composables/tone"
import { classLabel, classSelectOptions, submissionClockText, submissionDayText } from "./utils"
import { useTone } from "shared/composables/tone"
import { classLabel, classSelectOptions } from "utils/classes"
import { submissionClockText, submissionDayText } from "./utils"
/**
* 提交列表(设计稿「提交列表重设计 · 定稿」):左边列表、右边常驻选中那条的代码。
-55
View File
@@ -1,4 +1,3 @@
import { NO_CLASS } from "@oj2/contract"
import { parseTime } from "utils/functions"
/**
@@ -33,57 +32,3 @@ export function submissionDayText(time: string) {
if (day.slice(0, 4) === now.slice(0, 4)) return parseTime(time, "M月D日")
return parseTime(time, "YYYY年M月D日")
}
/**
* 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法。
* null(接口里没填班级的号)和 NO_CLASS(筛选里的「没填班级」)都写成「没填班级」
*/
export function classLabel(className: string | null) {
if (className === null || className === NO_CLASS) return "没填班级"
if (!/^\d{3,}$/.test(className)) return className
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
type ClassOption = { label: string; value: string }
/**
* 全站的班级下拉都按年级分组:「25 级」底下列 251、252……,新的年级在上,组里按班号排
* (11 班排在 2 班后面)。年级是班号前两位,和排名的「本年级」同一个口径。
* `first` 给了就把那个年级提到最前(班级 PK 加班时先给同年级的)。
* `toOption` 换掉选项长相(题单的「n/m 人加入」、登录框的 ks 前缀);不是数字班号的
* 名字不分组,按原顺序跟在最后。
*/
export function groupClassOptions<T extends ClassOption = ClassOption>(
names: string[],
toOption: (name: string) => T = (name) => ({ label: classLabel(name), value: name }) as T,
first?: string,
) {
const groups = new Map<string, string[]>()
const rest: string[] = []
for (const name of new Set(names)) {
if (!/^\d{3,}$/.test(name)) {
rest.push(name)
continue
}
const grade = name.slice(0, 2)
groups.set(grade, [...(groups.get(grade) ?? []), name])
}
const grouped = [...groups]
.sort((a, b) => (a[0] === first ? -1 : b[0] === first ? 1 : b[0].localeCompare(a[0])))
.map(([grade, list]) => ({
type: "group" as const,
label: `${grade} 级`,
key: `grade-${grade}`,
children: list.sort((a, b) => a.localeCompare(b, "zh", { numeric: true })).map(toOption),
}))
return [...grouped, ...rest.map(toOption)]
}
/**
* 班级下拉的选项:配置里的班按年级分组,最后一项「没填班级」(这批号也要能和普通班一样
* 圈出来看)。带进来的班不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
*/
export function classSelectOptions(list: string[], current: string) {
const all = current && current !== NO_CLASS ? [current, ...list] : list
return [...groupClassOptions(all), { label: classLabel(NO_CLASS), value: NO_CLASS }]
}
@@ -4,7 +4,7 @@ import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { clearMood, setRankHidden, updateProfile } from "oj/api"
import RankAvatar from "oj/rank/components/RankAvatar.vue"
import { classLabel } from "oj/submission/utils"
import { classLabel } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useUserStore } from "shared/store/user"
import { durationToDays, parseTime } from "utils/functions"
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { useThemeVars } from "naive-ui"
import storage from "utils/storage"
import { useProblemStore } from "oj/store/problem"
import {
cloneDoc,
emptyDoc,
@@ -35,8 +34,17 @@ interface Props {
* false:图交给外面管(后台出题页的参考流程图,v-model:value),不存草稿
*/
draft?: boolean
/**
* 草稿存在哪个键下,由题目页给(见 ProblemEditor.vue 的 flowchartDraftKey)。原来是这里
* 自己读题目页的 store 和路由拼出来的,后台出题页也用这个组件,那边 store 是空的
*/
draftKey?: string
}
const { height = "calc(100vh - 134px)", draft = true } = defineProps<Props>()
const {
height = "calc(100vh - 134px)",
draft = true,
draftKey = "flowchart-v2",
} = defineProps<Props>()
const model = defineModel<FlowDoc | null>("value", { default: null })
const message = useMessage()
@@ -86,20 +94,7 @@ function redo() {
// ==================== 草稿 ====================
const problemStore = useProblemStore()
const route = useRoute()
/**
* 每道题一份草稿;题单入口单独一份:加入题单之前在题库里画到 A 的那张,不能一进题单就
* 摆在画布上(设计文档第 11 节 3,和代码草稿同理)。
* 键名带 v2:2026-10 之前的草稿是 vue-flow 的格式,这里读不了,也不去覆盖它
*/
const cacheKey = computed(() => {
const id = problemStore.problem?._id
if (!id) return "flowchart-v2"
const base = `flowchart-v2-problem-${id}`
const problemSetId = route.params.problemSetId
return problemSetId ? `${base}-problemset-${problemSetId}` : base
})
const cacheKey = computed(() => draftKey)
const savedAt = ref<Date | null>(null)
+1 -1
View File
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import RankAvatar from "oj/rank/components/RankAvatar.vue"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { classLabel, groupClassOptions } from "utils/classes"
import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
@@ -7,7 +7,7 @@ import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types"
import { darkCodeTheme, lightCodeTheme } from "../themes/codeTheme"
import { styleTheme } from "shared/extensions/baseTheme"
import { enhanceCompletion } from "shared/extensions/autocompletion"
import { enhanceCompletion, type SqlTables } from "shared/extensions/autocompletion"
import { languageExtension } from "shared/extensions/language"
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
@@ -38,6 +38,8 @@ interface Props {
* 光比对题号挡不住(见 problemPageContext 的 `collab`)
*/
collab?: boolean
/** SQL 题的表,给补全用(表名、字段名) */
sqlTables?: SqlTables
}
const {
@@ -49,6 +51,7 @@ const {
extraExtensions = [],
problemId = "",
collab = true,
sqlTables = [],
} = defineProps<Props>()
const code = defineModel<string>("value")
@@ -61,7 +64,7 @@ const extensions = computed(() => [
closeBrackets(),
isDark.value ? darkCodeTheme : lightCodeTheme,
autocompletion({
override: [enhanceCompletion(language), completeAnyWord],
override: [enhanceCompletion(language, () => sqlTables), completeAnyWord],
}),
getInitialExtension(),
...extraExtensions,
@@ -5,7 +5,7 @@ import type {
CompletionSource,
} from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import { useProblemStore } from "oj/store/problem"
import type { SqlDisplay } from "@oj2/contract"
import type { LANGUAGE } from "utils/types"
import { c } from "./c"
import { python } from "./python"
@@ -23,9 +23,10 @@ const chineseAnnotations: Record<string, ChineseCompletion[]> = {
sql,
}
export type SqlTables = SqlDisplay["tables"]
// SQL 题:当前题目的表名和字段名补全,数据来自 sql_display
function sqlSchemaCompletions(): Completion[] {
const tables = useProblemStore().problem?.sqlDisplay?.tables ?? []
function sqlSchemaCompletions(tables: SqlTables): Completion[] {
return tables.flatMap((table) => [
{
label: table.name,
@@ -45,7 +46,14 @@ function sqlSchemaCompletions(): Completion[] {
])
}
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
/**
* `sqlTables` 由用编辑器的地方给(题目页给这道题的表)。原来是这里直接读题目页的 store:
* 自学页的 SQL 代码块会补出上一道看过的题的表名
*/
export function enhanceCompletion(
language: LANGUAGE,
sqlTables: () => SqlTables = () => [],
): CompletionSource {
return async function (context: CompletionContext): Promise<CompletionResult | null> {
const word = context.matchBefore(/\w+/)
if (!word && !context.explicit) return null
@@ -80,7 +88,7 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
)
if (trulyLanguage === "sql") {
completions.push(...sqlSchemaCompletions())
completions.push(...sqlSchemaCompletions(sqlTables()))
}
return {
+7 -13
View File
@@ -1,3 +1,4 @@
import { ADMIN_ROLES, TEACHER_ROLES, type AdminType } from "@oj2/contract"
import { PROBLEM_PERMISSION, STORAGE_KEY, USER_TYPE } from "utils/constants"
import storage from "utils/storage"
import type { Profile, SessionUser } from "utils/types"
@@ -23,25 +24,18 @@ export const useUserStore = defineStore("user", () => {
const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value)
const isAdminRole = computed(
() =>
!demoMode.value &&
(user.value?.adminType === USER_TYPE.STUDENT_ADMIN ||
user.value?.adminType === USER_TYPE.TEACHER_ADMIN ||
user.value?.adminType === USER_TYPE.SUPER_ADMIN),
)
// 分组用契约里的白名单(roles.ts,全仓唯一的定义处),别在这里再列一遍角色
const hasRole = (roles: readonly AdminType[]) =>
!demoMode.value && !!user.value && (roles as readonly string[]).includes(user.value.adminType)
const isAdminRole = computed(() => hasRole(ADMIN_ROLES))
const isStudentAdmin = computed(
() => !demoMode.value && user.value?.adminType === USER_TYPE.STUDENT_ADMIN,
)
const isTeacherAdmin = computed(
() => !demoMode.value && user.value?.adminType === USER_TYPE.TEACHER_ADMIN,
)
const isTeacherOrAbove = computed(
() =>
!demoMode.value &&
(user.value?.adminType === USER_TYPE.TEACHER_ADMIN ||
user.value?.adminType === USER_TYPE.SUPER_ADMIN),
)
const isTeacherOrAbove = computed(() => hasRole(TEACHER_ROLES))
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
const hasProblemPermission = computed(
() => !demoMode.value && user.value?.problemPermission !== PROBLEM_PERMISSION.NONE,
+55
View File
@@ -0,0 +1,55 @@
import { NO_CLASS } from "@oj2/contract"
/**
* 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法。
* null(接口里没填班级的号)和 NO_CLASS(筛选里的「没填班级」)都写成「没填班级」
*/
export function classLabel(className: string | null) {
if (className === null || className === NO_CLASS) return "没填班级"
if (!/^\d{3,}$/.test(className)) return className
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
type ClassOption = { label: string; value: string }
/**
* 全站的班级下拉都按年级分组:「25 级」底下列 251、252……,新的年级在上,组里按班号排
* (11 班排在 2 班后面)。年级是班号前两位,和排名的「本年级」同一个口径。
* `first` 给了就把那个年级提到最前(班级 PK 加班时先给同年级的)。
* `toOption` 换掉选项长相(题单的「n/m 人加入」、登录框的 ks 前缀);不是数字班号的
* 名字不分组,按原顺序跟在最后。
*/
export function groupClassOptions<T extends ClassOption = ClassOption>(
names: string[],
toOption: (name: string) => T = (name) => ({ label: classLabel(name), value: name }) as T,
first?: string,
) {
const groups = new Map<string, string[]>()
const rest: string[] = []
for (const name of new Set(names)) {
if (!/^\d{3,}$/.test(name)) {
rest.push(name)
continue
}
const grade = name.slice(0, 2)
groups.set(grade, [...(groups.get(grade) ?? []), name])
}
const grouped = [...groups]
.sort((a, b) => (a[0] === first ? -1 : b[0] === first ? 1 : b[0].localeCompare(a[0])))
.map(([grade, list]) => ({
type: "group" as const,
label: `${grade} 级`,
key: `grade-${grade}`,
children: list.sort((a, b) => a.localeCompare(b, "zh", { numeric: true })).map(toOption),
}))
return [...grouped, ...rest.map(toOption)]
}
/**
* 班级下拉的选项:配置里的班按年级分组,最后一项「没填班级」(这批号也要能和普通班一样
* 圈出来看)。带进来的班不在配置里(老班、手打的)也要显示得出来,不然框里是空的却在筛
*/
export function classSelectOptions(list: string[], current: string) {
const all = current && current !== NO_CLASS ? [current, ...list] : list
return [...groupClassOptions(all), { label: classLabel(NO_CLASS), value: NO_CLASS }]
}