fix(老师权限): 老师在自己比赛里点题目「可见」没反应、看不到学生的智能分析入口等一批权限错位
- 比赛题的可见开关按题目作者判归属,从题库加进比赛的题作者是别人,老师点了 404 且无提示; 改成和编辑、删除一样按比赛创建者判(canEdit) - 学生主页「智能分析」和「查看指定用户」只给超管,后端本来就允许老师;改成老师可见 - 排名页班级详情的「AI 分析」学生也看得到,点了权限不足;只给老师显示 - 班级 PK 页解构 store 丢了响应性,资料晚到时老师看不到「AI 分析」 - 学生管理员能给任何人的流程图重新评分且不限流;别人的提交只有老师能重评,免限流也只给老师 - 超管没退登就过期的机房电脑上,下一个登录的老师继承演示模式、看不到后台和看板且关不掉; 演示模式只对超管生效 - 老师的出题权限能设成「无权限」,设了比赛题目页全是权限不足;后端兜成「仅自己」, 编辑框里老师不再给这个选项 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bde387e8bc
commit
30f34f0e36
10 files changed
+45
-15
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
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { unknownDisplayIds } from "./submission-statistics"
|
|||||||
import {
|
import {
|
||||||
classCondition,
|
classCondition,
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
|
isTeacherOrAbove,
|
||||||
matchedUsers,
|
matchedUsers,
|
||||||
asRecord,
|
asRecord,
|
||||||
parseDisplayIds,
|
parseDisplayIds,
|
||||||
@@ -663,11 +664,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(
|
||||||
|
|||||||
@@ -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