Compare commits
2
Commits
bde387e8bc
...
af2ca31de5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2ca31de5 | ||
|
|
30f34f0e36 |
No files matched your search
@@ -57,6 +57,9 @@ function classNameOf(
|
|||||||
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission:
|
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission:
|
||||||
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
||||||
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
|
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
|
||||||
|
*
|
||||||
|
* 老师比旧后端多一条:不能是 None。比赛归老师管,而比赛题的接口挂的是
|
||||||
|
* requireProblemPermission —— 老师设成 None 就是进得了比赛、点题目全是「权限不足」。
|
||||||
*/
|
*/
|
||||||
function normalizePermission(
|
function normalizePermission(
|
||||||
adminType: AdminType,
|
adminType: AdminType,
|
||||||
@@ -64,6 +67,7 @@ function normalizePermission(
|
|||||||
): ProblemPermission {
|
): ProblemPermission {
|
||||||
if (adminType === "Super Admin") return "All"
|
if (adminType === "Super Admin") return "All"
|
||||||
if (adminType === "Regular User") return "None"
|
if (adminType === "Regular User") return "None"
|
||||||
|
if (adminType === "Teacher Admin" && requested === "None") return "Own"
|
||||||
return requested || "Own"
|
return requested || "Own"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ function canManageAll(user: AuthUser) {
|
|||||||
* 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。
|
* 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。
|
||||||
* 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
|
* 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
|
||||||
*/
|
*/
|
||||||
async function canEdit(user: AuthUser, problem: ProblemRow) {
|
export async function canEdit(
|
||||||
|
user: AuthUser,
|
||||||
|
problem: Pick<ProblemRow, "contestId" | "createdById">,
|
||||||
|
) {
|
||||||
if (user.adminType === "Super Admin") return true
|
if (user.adminType === "Super Admin") return true
|
||||||
if (problem.contestId === null) {
|
if (problem.contestId === null) {
|
||||||
return canManageAll(user) || problem.createdById === user.id
|
return canManageAll(user) || problem.createdById === user.id
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { JudgeStatus } from "../../judge/status"
|
|||||||
import { completeChat } from "../../services/ai"
|
import { completeChat } from "../../services/ai"
|
||||||
import { localTime, localYear } from "../../time"
|
import { localTime, localYear } from "../../time"
|
||||||
import { queryInteger, rounded } from "../helpers"
|
import { queryInteger, rounded } from "../helpers"
|
||||||
import { findTagsByName, normalizeTagNames } from "./problem"
|
import { canEdit, findTagsByName, normalizeTagNames } from "./problem"
|
||||||
|
|
||||||
export const adminTagRoutes = new Hono<AppEnv>()
|
export const adminTagRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -252,6 +252,7 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
|
|||||||
id: schema.problem.id,
|
id: schema.problem.id,
|
||||||
visible: schema.problem.visible,
|
visible: schema.problem.visible,
|
||||||
createdById: schema.problem.createdById,
|
createdById: schema.problem.createdById,
|
||||||
|
contestId: schema.problem.contestId,
|
||||||
})
|
})
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(eq(schema.problem.id, id))
|
.where(eq(schema.problem.id, id))
|
||||||
@@ -260,7 +261,9 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
|
|||||||
// AttributeError(500)。这里正常返回 404。
|
// AttributeError(500)。这里正常返回 404。
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
// 和编辑、删除同一个口径(canEdit):比赛题看比赛的创建者。原来只看题目的创建者,
|
||||||
|
// 而从题库加进比赛的题保留原作者,老师在自己比赛里点「可见」一律 404
|
||||||
|
if (!(await canEdit(user, problem))) {
|
||||||
return failure(c, 404, "problem-not-found", "题目不存在")
|
return failure(c, 404, "problem-not-found", "题目不存在")
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type FlowchartSubmission,
|
type FlowchartSubmission,
|
||||||
NO_CLASS,
|
NO_CLASS,
|
||||||
|
STUDENT_ROLES,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -30,6 +31,7 @@ import { unknownDisplayIds } from "./submission-statistics"
|
|||||||
import {
|
import {
|
||||||
classCondition,
|
classCondition,
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
|
isTeacherOrAbove,
|
||||||
matchedUsers,
|
matchedUsers,
|
||||||
asRecord,
|
asRecord,
|
||||||
parseDisplayIds,
|
parseDisplayIds,
|
||||||
@@ -56,7 +58,7 @@ function hiddenByProblemSet(
|
|||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
joinTimes: Map<number, string>,
|
joinTimes: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (row.userId !== user.id || isAdminRole(user)) return false
|
if (row.userId !== user.id || isTeacherOrAbove(user)) return false
|
||||||
const joinTime = joinTimes.get(row.problemId)
|
const joinTime = joinTimes.get(row.problemId)
|
||||||
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
|
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
|
||||||
}
|
}
|
||||||
@@ -68,12 +70,13 @@ function canView(
|
|||||||
joinTimes: Map<number, string>,
|
joinTimes: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (hiddenByProblemSet(user, row, joinTimes)) return false
|
if (hiddenByProblemSet(user, row, joinTimes)) return false
|
||||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
|
||||||
|
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
||||||
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||||||
if (isAdminRole(user)) return Promise.resolve(new Map<number, string>())
|
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
||||||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||||||
return problemSetJoinTimes(user.id, [...new Set(own)])
|
return problemSetJoinTimes(user.id, [...new Set(own)])
|
||||||
}
|
}
|
||||||
@@ -286,7 +289,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
|||||||
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
||||||
const className = c.req.query("className")?.trim()
|
const className = c.req.query("className")?.trim()
|
||||||
const onlyMyself =
|
const onlyMyself =
|
||||||
c.req.query("myself") === "1" || (!username && !className && user.adminType === "Regular User")
|
c.req.query("myself") === "1" ||
|
||||||
|
(!username && !className && STUDENT_ROLES.includes(user.adminType))
|
||||||
const filters: Array<SQL | undefined> = []
|
const filters: Array<SQL | undefined> = []
|
||||||
filters.push(
|
filters.push(
|
||||||
...(await Promise.all([
|
...(await Promise.all([
|
||||||
@@ -429,7 +433,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
|||||||
// 不相干的人,当没有花名册
|
// 不相干的人,当没有花名册
|
||||||
const roster =
|
const roster =
|
||||||
scoped && c.req.query("className")?.trim() !== NO_CLASS
|
scoped && c.req.query("className")?.trim() !== NO_CLASS
|
||||||
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
? matched.filter((row) => !row.isDisabled && STUDENT_ROLES.includes(row.adminType))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -531,7 +535,11 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
|||||||
.from(schema.flowchartSubmission)
|
.from(schema.flowchartSubmission)
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
|
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
|
||||||
.where(
|
.where(
|
||||||
and(where, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
|
and(
|
||||||
|
where,
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.groupBy(schema.user.id, schema.user.username, schema.user.className)
|
.groupBy(schema.user.id, schema.user.username, schema.user.className)
|
||||||
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
|
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
|
||||||
@@ -663,11 +671,15 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
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 failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||||
|
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
|
||||||
|
// 评分再抽一次,别人的提交只有老师能动
|
||||||
|
if (row.flowchart.userId !== user.id && !isTeacherOrAbove(user))
|
||||||
|
return failure(c, 403, "permission-denied", "权限不足")
|
||||||
if (![2, 3].includes(row.flowchart.status))
|
if (![2, 3].includes(row.flowchart.status))
|
||||||
return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
||||||
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
|
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
|
||||||
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
||||||
if (!isAdminRole(user)) {
|
if (!isTeacherOrAbove(user)) {
|
||||||
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
||||||
if (!throttle.allowed) {
|
if (!throttle.allowed) {
|
||||||
return failure(
|
return failure(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ADMIN_ROLES, NO_CLASS, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
|
import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
|
||||||
|
|
||||||
import { and, count, eq, ilike, isNull, notInArray, sql } from "drizzle-orm"
|
import { and, count, eq, ilike, inArray, isNull, notInArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import type { AuthUser } from "../auth/session"
|
import type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
@@ -163,12 +163,12 @@ export async function countFailedSubmissions(userId: number, problemId: number)
|
|||||||
* 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。
|
* 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。
|
||||||
* 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。
|
* 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。
|
||||||
*
|
*
|
||||||
* NO_CLASS 只圈普通用户:老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交
|
* NO_CLASS 只圈学生(含学生管理员,见 STUDENT_ROLES):老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交
|
||||||
* 会混进「没填班级」,还算进做完的人数里(切课那边也只算普通学生,两边口径一致)
|
* 会混进「没填班级」,还算进做完的人数里(切课那边也只算学生,两边口径一致)
|
||||||
*/
|
*/
|
||||||
export function classCondition(className: string) {
|
export function classCondition(className: string) {
|
||||||
return className === NO_CLASS
|
return className === NO_CLASS
|
||||||
? and(isNull(schema.user.className), eq(schema.user.adminType, "Regular User"))
|
? and(isNull(schema.user.className), inArray(schema.user.adminType, [...STUDENT_ROLES]))
|
||||||
: eq(schema.user.className, className)
|
: eq(schema.user.className, className)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ProblemStats } from "@oj2/contract"
|
import { STUDENT_ROLES, type ProblemStats } from "@oj2/contract"
|
||||||
import { and, count, eq, inArray, sql } from "drizzle-orm"
|
import { and, count, eq, inArray, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ async function classRows(problemId: number): Promise<NonNullable<ProblemStats["c
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(schema.user.className, names),
|
inArray(schema.user.className, names),
|
||||||
eq(schema.user.adminType, "Regular User"),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -399,10 +399,7 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
|||||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [problemSet] = await db
|
const [problemSet] = await db
|
||||||
.select({
|
.select({ id: schema.problemset.id })
|
||||||
id: schema.problemset.id,
|
|
||||||
createdById: schema.problemset.createdById,
|
|
||||||
})
|
|
||||||
.from(schema.problemset)
|
.from(schema.problemset)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -412,11 +409,10 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
|
// 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
||||||
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
// 也是任何老师看任何班,这里和它们一致。后台那条(admin/problemset.ts 的 loadOwned)
|
||||||
// 越权报「不存在」,不泄露题单存在与否。
|
// 能改能删,仍然只给创建者
|
||||||
const user = c.get("user")!
|
if (!problemSet) {
|
||||||
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
|
|
||||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
}
|
}
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
NO_CLASS,
|
NO_CLASS,
|
||||||
|
STUDENT_ROLES,
|
||||||
type ProblemLanguage,
|
type ProblemLanguage,
|
||||||
type SubmissionLessons,
|
type SubmissionLessons,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
@@ -388,8 +389,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
filters.push(matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`)
|
filters.push(matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`)
|
||||||
}
|
}
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
|
// 花名册:只有未禁用的学生(含学生管理员)算进班级人数和「谁没做」,老师不进分母
|
||||||
const enrolled = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
const enrolled = matched.filter((row) => !row.isDisabled && STUDENT_ROLES.includes(row.adminType))
|
||||||
|
|
||||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||||
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
||||||
@@ -524,7 +525,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
const rosterIds = new Set(rosterRows.map((row) => row.id))
|
const rosterIds = new Set(rosterRows.map((row) => row.id))
|
||||||
const attemptedRows = perUser.filter((row) => {
|
const attemptedRows = perUser.filter((row) => {
|
||||||
if (isDone(row)) return false
|
if (isDone(row)) return false
|
||||||
return scoped ? rosterIds.has(row.userId) : !row.isDisabled && row.adminType === "Regular User"
|
if (scoped) return rosterIds.has(row.userId)
|
||||||
|
return !row.isDisabled && row.adminType !== null && STUDENT_ROLES.includes(row.adminType)
|
||||||
})
|
})
|
||||||
const dataAttempted = attemptedRows.map((row) => ({
|
const dataAttempted = attemptedRows.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -587,10 +589,14 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
|
|||||||
className: schema.user.className,
|
className: schema.user.className,
|
||||||
})
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
// 只要普通学生:老师试题的提交不该出现在「谁做了几次」里
|
// 只要学生(含学生管理员):老师试题的提交不该出现在「谁做了几次」里
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
.where(
|
.where(
|
||||||
and(...filters, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
|
and(
|
||||||
|
...filters,
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(schema.submission.createTime))
|
.orderBy(desc(schema.submission.createTime))
|
||||||
.limit(GRID_LIMIT + 1)
|
.limit(GRID_LIMIT + 1)
|
||||||
@@ -672,7 +678,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(schema.user.className, names as string[]),
|
inArray(schema.user.className, names as string[]),
|
||||||
eq(schema.user.adminType, "Regular User"),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -892,7 +898,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(schema.user.className, lessonClasses),
|
inArray(schema.user.className, lessonClasses),
|
||||||
eq(schema.user.adminType, "Regular User"),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { getBooleanOption } from "../services/options"
|
|||||||
import { problemSetJoinTimes } from "../services/problemset"
|
import { problemSetJoinTimes } from "../services/problemset"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
|
import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
||||||
import {
|
import {
|
||||||
classNameFilter,
|
classNameFilter,
|
||||||
exactUsernameFilter,
|
exactUsernameFilter,
|
||||||
@@ -289,26 +289,22 @@ function canViewSubmission(
|
|||||||
user: AuthUser | null,
|
user: AuthUser | null,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
problem: { createdById: number },
|
problem: { createdById: number },
|
||||||
contest: typeof schema.contest.$inferSelect | null,
|
|
||||||
problemSetJoinTime?: Map<number, string>,
|
problemSetJoinTime?: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,管理员不受限,对齐旧后端
|
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,老师不受限。
|
||||||
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。
|
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
||||||
if (row.userId === user.id && !isAdminRole(user)) {
|
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
||||||
const joinTime = problemSetJoinTime?.get(row.problemId)
|
const joinTime = problemSetJoinTime?.get(row.problemId)
|
||||||
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
||||||
}
|
}
|
||||||
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里
|
// 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
|
||||||
// (contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。
|
// 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
|
||||||
// 老师和超管不受影响 —— 他们不参赛。旧后端这里是 `not user.is_regular_user()`,
|
// 学生管理员同样放行;OJ2 先收紧到「比赛没结束时不给」,现在整条都不给。
|
||||||
// 学生管理员同样放行,所以这条是 OJ2 相对旧栈**收紧**的一处,不是修回归。
|
|
||||||
//
|
//
|
||||||
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
|
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
|
||||||
// 他早就知道答案了,挡他没有意义。
|
// 他早就知道答案了,挡他没有意义。
|
||||||
const elevated =
|
const elevated = isTeacherOrAbove(user)
|
||||||
isAdminRole(user) &&
|
|
||||||
!(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
|
|
||||||
// 这三条就是全部:别人的代码谁都看不到,比赛内外一样。
|
// 这三条就是全部:别人的代码谁都看不到,比赛内外一样。
|
||||||
// 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉,
|
// 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉,
|
||||||
// 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条
|
// 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条
|
||||||
@@ -404,10 +400,10 @@ async function submissionDetail(id: string, user: AuthUser) {
|
|||||||
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
||||||
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
||||||
const joinTimes =
|
const joinTimes =
|
||||||
isAdminRole(user) || row.submission.userId !== user.id
|
isTeacherOrAbove(user) || row.submission.userId !== user.id
|
||||||
? undefined
|
? undefined
|
||||||
: await problemSetJoinTimes(user.id, [row.submission.problemId])
|
: await problemSetJoinTimes(user.id, [row.submission.problemId])
|
||||||
if (!canViewSubmission(user, row.submission, row.problem, row.contest, joinTimes)) return null
|
if (!canViewSubmission(user, row.submission, row.problem, joinTimes)) return null
|
||||||
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
||||||
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
||||||
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
|
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
|
||||||
@@ -574,7 +570,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
])
|
])
|
||||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||||
const [joinTimes, problemsetTitles] = await Promise.all([
|
const [joinTimes, problemsetTitles] = await Promise.all([
|
||||||
user && !isAdminRole(user)
|
user && !isTeacherOrAbove(user)
|
||||||
? problemSetJoinTimes(user.id, [
|
? problemSetJoinTimes(user.id, [
|
||||||
...new Set(
|
...new Set(
|
||||||
rows
|
rows
|
||||||
@@ -593,7 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
id: submission.id,
|
id: submission.id,
|
||||||
problemDisplayId: problem.displayId,
|
problemDisplayId: problem.displayId,
|
||||||
problemTitle: problem.title,
|
problemTitle: problem.title,
|
||||||
showLink: user ? canViewSubmission(user, submission, problem, null, joinTimes) : false,
|
showLink: user ? canViewSubmission(user, submission, problem, joinTimes) : false,
|
||||||
createTime: submission.createTime,
|
createTime: submission.createTime,
|
||||||
userId: submission.userId,
|
userId: submission.userId,
|
||||||
username: submission.username,
|
username: submission.username,
|
||||||
@@ -669,7 +665,7 @@ submissionRoutes.get(
|
|||||||
id: submission.id,
|
id: submission.id,
|
||||||
problemDisplayId: problem.displayId,
|
problemDisplayId: problem.displayId,
|
||||||
problemTitle: problem.title,
|
problemTitle: problem.title,
|
||||||
showLink: user ? canViewSubmission(user, submission, problem, contest) : false,
|
showLink: user ? canViewSubmission(user, submission, problem) : false,
|
||||||
createTime: submission.createTime,
|
createTime: submission.createTime,
|
||||||
userId: submission.userId,
|
userId: submission.userId,
|
||||||
username: submission.username,
|
username: submission.username,
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ const problemPermissionOptions: SelectOption[] = [
|
|||||||
{ label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN },
|
{ label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN },
|
||||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||||
]
|
]
|
||||||
|
// 老师不给「无权限」:比赛题的接口要出题权限,设了就是比赛题目页全报权限不足,后端也会兜成「仅自己」
|
||||||
|
const teacherPermissionOptions = problemPermissionOptions.slice(1)
|
||||||
|
|
||||||
async function listUsers() {
|
async function listUsers() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
@@ -336,7 +338,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
|||||||
>
|
>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="userEditing.problemPermission"
|
v-model:value="userEditing.problemPermission"
|
||||||
:options="problemPermissionOptions"
|
:options="
|
||||||
|
userEditing.adminType === USER_TYPE.TEACHER_ADMIN
|
||||||
|
? teacherPermissionOptions
|
||||||
|
: problemPermissionOptions
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-input
|
<n-input
|
||||||
v-if="userStore.isSuperAdmin"
|
v-if="userStore.isTeacherOrAbove"
|
||||||
v-model:value="urlUsername"
|
v-model:value="urlUsername"
|
||||||
placeholder="查看指定用户"
|
placeholder="查看指定用户"
|
||||||
clearable
|
clearable
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ ChartJS.register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const { isTeacherOrAbove } = useUserStore()
|
const { isTeacherOrAbove } = storeToRefs(useUserStore())
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
|
|||||||
@@ -843,7 +843,12 @@ watch(
|
|||||||
<n-tag type="success" size="large">
|
<n-tag type="success" size="large">
|
||||||
综合分: {{ classDetailData.compositeScore.toFixed(1) }}
|
综合分: {{ classDetailData.compositeScore.toFixed(1) }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-button type="info" :loading="classDetailAiLoading" @click="analyzeSingleClassWithAI">
|
<n-button
|
||||||
|
v-if="userStore.isTeacherOrAbove"
|
||||||
|
type="info"
|
||||||
|
:loading="classDetailAiLoading"
|
||||||
|
@click="analyzeSingleClassWithAI"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="ph:sparkle" />
|
<Icon icon="ph:sparkle" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ onMounted(() => {
|
|||||||
<h2>{{ profile.user.username }}</h2>
|
<h2>{{ profile.user.username }}</h2>
|
||||||
<p class="desc">{{ profile.mood }}</p>
|
<p class="desc">{{ profile.mood }}</p>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="userStore.isSuperAdmin"
|
v-if="userStore.isTeacherOrAbove"
|
||||||
type="info"
|
type="info"
|
||||||
secondary
|
secondary
|
||||||
@click="
|
@click="
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
const user = computed<SessionUser | null>(() => profile.value?.user ?? null)
|
const user = computed<SessionUser | null>(() => profile.value?.user ?? null)
|
||||||
const isAuthed = computed(() => !!user.value?.email)
|
const isAuthed = computed(() => !!user.value?.email)
|
||||||
|
|
||||||
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
|
|
||||||
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
|
||||||
|
|
||||||
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
|
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
|
||||||
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
|
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
|
||||||
const realIsSuperAdmin = computed(() => user.value?.adminType === USER_TYPE.SUPER_ADMIN)
|
const realIsSuperAdmin = computed(() => user.value?.adminType === USER_TYPE.SUPER_ADMIN)
|
||||||
|
|
||||||
|
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。
|
||||||
|
// 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管
|
||||||
|
// 生效。不然老师在超管用过的机房电脑上一登录,后台、看板、求助全没了,
|
||||||
|
// 而「退出演示」只给超管,他自己关不掉
|
||||||
|
const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||||
|
const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value)
|
||||||
|
|
||||||
const isAdminRole = computed(
|
const isAdminRole = computed(
|
||||||
() =>
|
() =>
|
||||||
!demoMode.value &&
|
!demoMode.value &&
|
||||||
@@ -46,8 +50,8 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||||
|
|
||||||
function toggleDemoMode() {
|
function toggleDemoMode() {
|
||||||
demoMode.value = !demoMode.value
|
demoSwitch.value = !demoSwitch.value
|
||||||
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
|
storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSubmissions = computed(() => {
|
const showSubmissions = computed(() => {
|
||||||
@@ -90,7 +94,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
|
|
||||||
function clearProfile() {
|
function clearProfile() {
|
||||||
profile.value = null
|
profile.value = null
|
||||||
demoMode.value = false
|
demoSwitch.value = false
|
||||||
// 登录框记的班级要跨退登活下来:机房一台机器对一个班,下课登出、下节课再来
|
// 登录框记的班级要跨退登活下来:机房一台机器对一个班,下课登出、下节课再来
|
||||||
// 还是同一个班,清掉的话每个人都得重新选一遍。它是机器的属性,不是谁的隐私。
|
// 还是同一个班,清掉的话每个人都得重新选一遍。它是机器的属性,不是谁的隐私。
|
||||||
const loginClass = storage.get(STORAGE_KEY.LOGIN_CLASS)
|
const loginClass = storage.get(STORAGE_KEY.LOGIN_CLASS)
|
||||||
|
|||||||
Reference in new issue
Block a user