Files
OJ2/apps/web/src/admin/user/list.vue
T
xuyueandClaude Opus 5.5 30f34f0e36 fix(老师权限): 老师在自己比赛里点题目「可见」没反应、看不到学生的智能分析入口等一批权限错位
- 比赛题的可见开关按题目作者判归属,从题库加进比赛的题作者是别人,老师点了 404 且无提示;
  改成和编辑、删除一样按比赛创建者判(canEdit)
- 学生主页「智能分析」和「查看指定用户」只给超管,后端本来就允许老师;改成老师可见
- 排名页班级详情的「AI 分析」学生也看得到,点了权限不足;只给老师显示
- 班级 PK 页解构 store 丢了响应性,资料晚到时老师看不到「AI 分析」
- 学生管理员能给任何人的流程图重新评分且不限流;别人的提交只有老师能重评,免限流也只给老师
- 超管没退登就过期的机房电脑上,下一个登录的老师继承演示模式、看不到后台和看板且关不掉;
  演示模式只对超管生效
- 老师的出题权限能设成「无权限」,设了比赛题目页全是权限不足;后端兜成「仅自己」,
  编辑框里老师不再给这个选项

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:04 +08:00

360 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api"
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { AdminUserForm } from "utils/types"
import { deleteUsers, editUser, getUserList, importUsers, resetPassword } from "../api"
import Actions from "./components/Actions.vue"
import UserNameCell from "./components/UserNameCell.vue"
import PasswordCell from "./components/PasswordCell.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router"
import TextCopy from "shared/components/TextCopy.vue"
const message = useMessage()
interface UserQuery {
keyword: string
type: string
orderBy: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<UserQuery>({
keyword: useRouteQuery("keyword", "").value,
type: useRouteQuery("type", "").value,
orderBy: useRouteQuery("orderBy", "").value,
})
const total = ref(0)
const users = ref<AdminUserForm[]>([])
const userEditing = ref<AdminUserForm | null>(null)
const adminOptions = [
{ label: "全部用户", value: "" },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
]
const sortOptions = [
{ label: "默认排序", value: "" },
{ label: "最近登录", value: "-last_login" },
{ label: "在线优先", value: "-online" },
]
const [create, toggleCreate] = useToggle(false)
const password = ref("")
// 已经点开的管理员密码,按 user id 记。同一个按钮再点一下收回去;listUsers() 里清空,
// 所以翻页/搜索/换筛选之后也一律回到打码状态 —— 不做定时自动隐藏,也不记进 localStorage。
const revealedPasswords = ref(new Set<number>())
const userIDs = ref<DataTableRowKey[]>([])
const rowKey = (row: AdminUserForm) => row.id
const columns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 220,
render: (row) => h(UserNameCell, { user: row }),
},
{
// 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码
title: "密码",
key: "raw_password",
width: 180,
render: (row) =>
h(PasswordCell, {
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
},
}),
},
{
title: "创建时间",
key: "create_time",
width: 200,
render: (row) => (row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : ""),
},
{
title: "上次登录",
key: "last_login",
width: 240,
// 在线是 5 分钟内有过活动(后端 auth/presence.ts),不是「有会话」——
// 会话能留 7 天。上次登录时间照旧显示,在线的人前面多一个标记
render: (row) =>
h(NFlex, { align: "center", size: "small" }, () => [
row.isOnline ? h(NTag, { type: "success", size: "small" }, () => "在线") : null,
row.lastLogin ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") : "从未登录",
]),
},
{
title: "真名",
key: "real_name",
width: 100,
render: (row) => h(TextCopy, () => row.realName),
},
{ title: "邮箱", key: "email", width: 260 },
{
key: "actions",
title: "选项",
width: 280,
fixed: "right",
render: (row) =>
h(Actions, {
user: row,
onDeleteUser: onDeleteUsers,
onUserBanned,
onOpenEditModal,
onResetPassword,
}),
},
]
const options: SelectOption[] = [
{ label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
]
const problemPermissionOptions: SelectOption[] = [
{ label: "无权限", value: PROBLEM_PERMISSION.NONE },
{ 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
const offset = (query.page - 1) * query.limit
const res = await getUserList(offset, query.limit, query.type, query.keyword, query.orderBy)
total.value = res.total
users.value = res.results
revealedPasswords.value.clear()
}
function chooseUsers(rowKeys: DataTableRowKey[]) {
userIDs.value = rowKeys
}
async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
await deleteUsers(toRaw(userIDs) as number[])
listUsers()
}
async function onResetPassword(user: AdminUserForm) {
const res = await resetPassword(user.id)
message.success(`【${user.username}】的密码已重置成【${res}】`)
users.value = users.value.map((it) => {
if (it.id === user.id && user.adminType === USER_TYPE.REGULAR_USER) {
it.rawPassword = res
}
return it
})
}
async function onUserBanned(user: AdminUserForm) {
users.value = users.value.map((it) => {
if (it.id === user.id) {
it.isDisabled = user.isDisabled
}
return it
})
}
function createNewUser() {
toggleCreate(true)
userEditing.value = {
id: 0,
username: "",
realName: "",
// 预填一个唯一的占位邮箱:这条路走的是导入接口,后端要求邮箱合法且不重复,
// 留空会直接被打回。想填真邮箱就改掉它
email: `user.${Math.random().toString(36).slice(2, 10)}@example.com`,
// 导入接口写死建成普通用户(后端 POST /admin/users),这里不摆一个建不出来的
// 角色。要给管理员权限,建完再用「编辑」改
adminType: "Regular User",
problemPermission: "None",
createTime: null,
lastLogin: null,
isDisabled: false,
isOnline: false,
rawPassword: null,
className: null,
password: "",
}
password.value = ""
}
function onOpenEditModal(user: AdminUserForm) {
userEditing.value = user
password.value = ""
}
function onCloseEditModal() {
userEditing.value = null
password.value = ""
toggleCreate(false)
}
async function handleEditUser() {
if (!userEditing.value) return
// 新建走的是导入接口,密码是必填的(后端 min(1)),留空只会换来一句 400
if (create.value && !password.value) {
message.error("新建用户必须填写密码")
return
}
if (password.value && password.value.length < 6) {
message.error("密码长度不得小于 6")
return
}
// http 拦截器只对 login-required / permission-denied 自动弹提示,
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
try {
if (create.value) {
const newUser = [
[
userEditing.value.username,
password.value,
userEditing.value.email ?? "",
userEditing.value.realName ?? "",
],
]
await importUsers(newUser)
listUsers()
} else {
const user = Object.assign(userEditing.value, {
password: password.value,
})
await editUser(user)
}
} catch (err) {
message.error("保存失败:" + errorMessage(err))
return
}
userEditing.value = null
password.value = ""
toggleCreate(false)
}
onMounted(listUsers)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
</script>
<template>
<PageHeader title="用户列表">
<template #actions>
<n-popconfirm v-if="userIDs.length" @positive-click="onDeleteUsers(userIDs)">
<template #trigger>
<n-button type="error" secondary>删除选中({{ userIDs.length }})</n-button>
</template>
确定删除选中的用户吗?删除后无法恢复!
</n-popconfirm>
<n-button @click="$router.push({ name: 'admin user generate' })">批量导入</n-button>
<n-button type="primary" @click="createNewUser">新建用户</n-button>
</template>
<template #filters>
<n-input
style="width: 220px"
v-model:value="query.keyword"
placeholder="用户名 / 真名 / 邮箱"
clearable
@clear="clearQuery"
/>
<n-select
v-model:value="query.type"
:options="adminOptions"
placeholder="选择用户类型"
style="width: 120px"
/>
<n-select
v-model:value="query.orderBy"
:options="sortOptions"
placeholder="排序方式"
style="width: 120px"
/>
</template>
</PageHeader>
<n-data-table
:data="users"
:columns="columns"
striped
:row-key="rowKey"
@update:checked-row-keys="chooseUsers"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
<n-modal
:mask-closable="false"
:show="!!userEditing"
preset="card"
:title="create ? '新建用户' : '编辑用户'"
style="width: 700px"
@close="onCloseEditModal"
>
<n-form label-placement="left" v-if="userEditing">
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi :span="1" label="用户">
<n-input v-model:value="userEditing.username" />
</n-form-item-gi>
<n-form-item-gi :span="1" label="真名">
<n-input v-model:value="userEditing.realName" />
</n-form-item-gi>
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf),
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 -->
<n-form-item-gi v-if="!create" :span="1" label="班级">
<n-input :value="userEditing.className" disabled placeholder="由用户名自动推导" />
</n-form-item-gi>
<n-form-item-gi :span="1" label="邮箱">
<n-input v-model:value="userEditing.email" />
</n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="类型">
<n-select v-model:value="userEditing.adminType" :options="options" />
</n-form-item-gi>
<n-form-item-gi :span="1" label="密码" label-style="color: red; font-weight: bold">
<n-input v-model:value="password" />
</n-form-item-gi>
<n-form-item-gi
v-if="
!create &&
(userEditing.adminType === USER_TYPE.STUDENT_ADMIN ||
userEditing.adminType === USER_TYPE.TEACHER_ADMIN)
"
:span="1"
label="出题权限"
>
<n-select
v-model:value="userEditing.problemPermission"
:options="
userEditing.adminType === USER_TYPE.TEACHER_ADMIN
? teacherPermissionOptions
: problemPermissionOptions
"
/>
</n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="是否封禁">
<n-switch v-model:value="userEditing.isDisabled">封号</n-switch>
</n-form-item-gi>
</n-grid>
<n-flex justify="end">
<n-button @click="onCloseEditModal">取消</n-button>
<n-button type="primary" @click="handleEditUser">保存</n-button>
</n-flex>
</n-form>
</n-modal>
</template>