diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 45f244f..0618260 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -57,6 +57,9 @@ function classNameOf( * 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission: * 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。 * 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。 + * + * 老师比旧后端多一条:不能是 None。比赛归老师管,而比赛题的接口挂的是 + * requireProblemPermission —— 老师设成 None 就是进得了比赛、点题目全是「权限不足」。 */ function normalizePermission( adminType: AdminType, @@ -64,6 +67,7 @@ function normalizePermission( ): ProblemPermission { if (adminType === "Super Admin") return "All" if (adminType === "Regular User") return "None" + if (adminType === "Teacher Admin" && requested === "None") return "Own" return requested || "Own" } diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index a80e64d..e614577 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -52,7 +52,10 @@ function canManageAll(user: AuthUser) { * 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。 * 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。 */ -async function canEdit(user: AuthUser, problem: ProblemRow) { +export async function canEdit( + user: AuthUser, + problem: Pick, +) { if (user.adminType === "Super Admin") return true if (problem.contestId === null) { return canManageAll(user) || problem.createdById === user.id diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index f9afbb3..1daad14 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -35,7 +35,7 @@ import { JudgeStatus } from "../../judge/status" import { completeChat } from "../../services/ai" import { localTime, localYear } from "../../time" import { queryInteger, rounded } from "../helpers" -import { findTagsByName, normalizeTagNames } from "./problem" +import { canEdit, findTagsByName, normalizeTagNames } from "./problem" export const adminTagRoutes = new Hono() @@ -252,6 +252,7 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async ( id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById, + contestId: schema.problem.contestId, }) .from(schema.problem) .where(eq(schema.problem.id, id)) @@ -260,7 +261,9 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async ( // AttributeError(500)。这里正常返回 404。 if (!problem) return failure(c, 404, "problem-not-found", "题目不存在") 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", "题目不存在") } await db diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 4e2af34..24d5f14 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -30,6 +30,7 @@ import { unknownDisplayIds } from "./submission-statistics" import { classCondition, isAdminRole, + isTeacherOrAbove, matchedUsers, asRecord, parseDisplayIds, @@ -663,11 +664,15 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { .limit(1) if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart]))) 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)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry") // canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。 // 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法 - if (!isAdminRole(user)) { + if (!isTeacherOrAbove(user)) { const throttle = await consumeToken("user", flowchartThrottleKey(user.id)) if (!throttle.allowed) { return failure( diff --git a/apps/web/src/admin/user/list.vue b/apps/web/src/admin/user/list.vue index 6d72294..6126951 100644 --- a/apps/web/src/admin/user/list.vue +++ b/apps/web/src/admin/user/list.vue @@ -130,6 +130,8 @@ const problemPermissionOptions: SelectOption[] = [ { label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN }, { label: "管理全部题目", value: PROBLEM_PERMISSION.ALL }, ] +// 老师不给「无权限」:比赛题的接口要出题权限,设了就是比赛题目页全报权限不足,后端也会兜成「仅自己」 +const teacherPermissionOptions = problemPermissionOptions.slice(1) async function listUsers() { if (query.page < 1) query.page = 1 @@ -336,7 +338,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers) > diff --git a/apps/web/src/oj/ai/analysis.vue b/apps/web/src/oj/ai/analysis.vue index 34b16df..fc5961b 100644 --- a/apps/web/src/oj/ai/analysis.vue +++ b/apps/web/src/oj/ai/analysis.vue @@ -5,7 +5,7 @@ 请选择时间范围,智能分析学习情况 综合分: {{ classDetailData.compositeScore.toFixed(1) }} - + diff --git a/apps/web/src/oj/user/index.vue b/apps/web/src/oj/user/index.vue index d481242..63bc7fb 100644 --- a/apps/web/src/oj/user/index.vue +++ b/apps/web/src/oj/user/index.vue @@ -165,7 +165,7 @@ onMounted(() => {

{{ profile.user.username }}

{{ profile.mood }}

{ const user = computed(() => profile.value?.user ?? null) const isAuthed = computed(() => !!user.value?.email) - // 演示模式:超管临时把界面伪装成普通学生,方便上课投屏 - const demoMode = ref(storage.get(STORAGE_KEY.DEMO_MODE) ?? false) - // 不受伪装影响的真实身份,只用于判断能否切换演示模式。 // 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。 const realIsSuperAdmin = computed(() => user.value?.adminType === USER_TYPE.SUPER_ADMIN) + // 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。 + // 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管 + // 生效。不然老师在超管用过的机房电脑上一登录,后台、看板、求助全没了, + // 而「退出演示」只给超管,他自己关不掉 + const demoSwitch = ref(storage.get(STORAGE_KEY.DEMO_MODE) ?? false) + const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value) + const isAdminRole = computed( () => !demoMode.value && @@ -46,8 +50,8 @@ export const useUserStore = defineStore("user", () => { const canToggleDemoMode = computed(() => realIsSuperAdmin.value) function toggleDemoMode() { - demoMode.value = !demoMode.value - storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value) + demoSwitch.value = !demoSwitch.value + storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value) } const showSubmissions = computed(() => { @@ -90,7 +94,7 @@ export const useUserStore = defineStore("user", () => { function clearProfile() { profile.value = null - demoMode.value = false + demoSwitch.value = false // 登录框记的班级要跨退登活下来:机房一台机器对一个班,下课登出、下节课再来 // 还是同一个班,清掉的话每个人都得重新选一遍。它是机器的属性,不是谁的隐私。 const loginClass = storage.get(STORAGE_KEY.LOGIN_CLASS)