feat(后台用户): 用户列表按班级看、「重置密码」常驻;批量建号粘进名单当场列出会建成的账号,重名和已有的先点出来,建好能下载名单、打印账号条

用户列表去掉 ID、真名、占位邮箱和四颗彩色按钮。下载的名单开头带 BOM,Excel 打开中文不再乱码。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 13:56:03 +08:00
1 parent ba823ecb7d
commit 5561e0bc3a
5 files changed
+607 -158

No files matched your search

+18 -2
View File
@@ -51,6 +51,7 @@ import type {
GenerateTestInputsRequest, GenerateTestInputsRequest,
GenerateTestInputsResponse, GenerateTestInputsResponse,
TestCaseFiles, TestCaseFiles,
CheckUsernamesResponse,
} from "@oj2/contract" } from "@oj2/contract"
export function getBaseInfo() { export function getBaseInfo() {
@@ -182,7 +183,15 @@ export function getAdminUserRank(offset: number, limit: number, keyword: string)
} }
// 用户列表 // 用户列表
export function getUserList(offset = 0, limit = 10, type = "", keyword: string, orderBy = "") { export function getUserList(
offset = 0,
limit = 10,
type = "",
keyword: string,
orderBy = "",
/** 班级,NO_CLASS 是「没填班级」 */
className = "",
) {
return api.get<AdminUserList>("admin/users", { return api.get<AdminUserList>("admin/users", {
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin"; // "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
// "-online" 是新增的,原样透传 // "-online" 是新增的,原样透传
@@ -192,10 +201,16 @@ export function getUserList(offset = 0, limit = 10, type = "", keyword: string,
keyword, keyword,
type, type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy, orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
className: className || undefined,
}, },
}) })
} }
/** 批量建号之前查重:这些用户名哪些已经有了 */
export function checkUsernames(usernames: string[]) {
return api.post<CheckUsernamesResponse>("admin/users/check", { usernames })
}
// 编辑用户 // 编辑用户
export function editUser(user: AdminUserForm) { export function editUser(user: AdminUserForm) {
return api.put<AdminUser>(`admin/users/${user.id}`, { return api.put<AdminUser>(`admin/users/${user.id}`, {
@@ -216,8 +231,9 @@ export async function resetPassword(userID: number) {
} }
// 导入用户 // 导入用户
/** 批量建号。addedClasses:这批里的新班级,后端已经顺手加进了班级列表 */
export function importUsers(users: string[][]) { export function importUsers(users: string[][]) {
return api.post("admin/users", { users }) return api.post<{ imported: number; addedClasses: string[] }>("admin/users", { users })
} }
// 批量删除用户 // 批量删除用户
+36 -21
View File
@@ -1,7 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { editUser } from "admin/api" import { editUser } from "admin/api"
import type { AdminUserForm } from "utils/types" import type { AdminUserForm } from "utils/types"
/**
* 用户列表一行的操作(设计稿「后台用户与批量建号」UsList):最常用的「重置密码」常驻
* (学生忘了密码是后台进得最多的一件事),其余收进 ⋯。原来四颗彩色按钮并排,
* 1366 的屏上把「上次登录」挤成了半截
*/
interface Props { interface Props {
user: AdminUserForm user: AdminUserForm
} }
@@ -13,33 +19,42 @@ const emit = defineEmits<{
(e: "resetPassword", value: AdminUserForm): void (e: "resetPassword", value: AdminUserForm): void
}>() }>()
const dialog = useDialog()
async function banUser() { async function banUser() {
props.user.isDisabled = !props.user.isDisabled props.user.isDisabled = !props.user.isDisabled
await editUser(props.user) await editUser(props.user)
emit("userBanned", props.user) emit("userBanned", props.user)
} }
const menuOptions = computed<DropdownOption[]>(() => [
{ label: "编辑", key: "edit" },
{ label: props.user.isDisabled ? "解除禁用" : "禁用", key: "ban" },
{ type: "divider", key: "divider" },
{ label: "删除", key: "delete" },
])
function onMenu(key: string) {
if (key === "edit") emit("openEditModal", props.user)
else if (key === "ban") banUser()
else if (key === "delete") {
dialog.warning({
title: `删除 ${props.user.username}?`,
content: "删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。",
positiveText: "删除",
negativeText: "不删",
onPositiveClick: () => emit("deleteUser", [props.user.id]),
})
}
}
</script> </script>
<template> <template>
<n-flex> <n-flex :size="4" justify="end" :wrap="false">
<n-button size="small" type="error" secondary @click="$emit('resetPassword', props.user)"> <n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
重置密码 <n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
</n-button> <n-button size="small" quaternary aria-label="更多">
<n-button size="small" type="primary" secondary @click="$emit('openEditModal', props.user)"> <template #icon><Icon icon="ph:dots-three-bold" /></template>
编辑 </n-button>
</n-button> </n-dropdown>
<n-button
size="small"
secondary
:type="props.user.isDisabled ? 'info' : 'error'"
@click="banUser"
>
{{ props.user.isDisabled ? "解封" : "封号" }}
</n-button>
<n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])">
<template #trigger>
<n-button size="small" secondary type="warning">删除</n-button>
</template>
确定删除这个用户吗?删除后无法恢复!
</n-popconfirm>
</n-flex> </n-flex>
</template> </template>
@@ -11,20 +11,27 @@ const props = defineProps<Props>()
const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER) const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER)
</script> </script>
<template> <template>
<n-flex align="center"> <!-- 用户名在前(老师要认的是它),角色、禁用这些标签跟在后面 -->
<n-tag v-if="props.user.isDisabled" type="error" size="small"> 封号中 </n-tag> <n-flex align="center" :size="6" :wrap="false">
<n-tag v-if="hasRoleTag" :type="getUserRole(props.user.adminType).type" size="small"> <TextCopy>{{ props.user.username }}</TextCopy>
<n-tag v-if="props.user.isDisabled" type="error" size="small" :bordered="false"> 已禁用 </n-tag>
<n-tag
v-if="hasRoleTag"
:type="getUserRole(props.user.adminType).type"
size="small"
:bordered="false"
>
{{ getUserRole(props.user.adminType).label }} {{ getUserRole(props.user.adminType).label }}
</n-tag> </n-tag>
<n-tag <n-tag
size="small"
v-if=" v-if="
props.user.adminType === USER_TYPE.STUDENT_ADMIN || props.user.adminType === USER_TYPE.STUDENT_ADMIN ||
props.user.adminType === USER_TYPE.TEACHER_ADMIN props.user.adminType === USER_TYPE.TEACHER_ADMIN
" "
size="small"
:bordered="false"
> >
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部" : "仅自己" }} {{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部题目" : "自己的题" }}
</n-tag> </n-tag>
<TextCopy>{{ props.user.username }}</TextCopy>
</n-flex> </n-flex>
</template> </template>
+422 -86
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { classLabel } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { import {
CLASS_NAME_MAX_DIGITS, CLASS_NAME_MAX_DIGITS,
@@ -7,113 +9,447 @@ import {
CLASS_NAME_MIN_DIGITS, CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE, CLASS_NAME_MIN_VALUE,
} from "utils/constants" } from "utils/constants"
import { importUsers } from "../api" import { parseTime } from "utils/functions"
import { checkUsernames, importUsers } from "../api"
/**
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
*
* 一年就开学那几天用(2026-09 七批共 440 个),所以要的是「粘进去就看到会建成什么」:
* 右边当场列出用户名和密码,同批重名、已经有的账号在建之前就点出来哪一行、怎么改。
* 建好之后给「下载名单」和「打印账号条」(一人一张,剪开发下去),原来是一点就自动下载
* 一个 CSV,Excel 打开中文还是乱码。新班级后端会顺手加进班级列表。
*/
const message = useMessage() const message = useMessage()
const prefix = ref(0)
const classNo = ref("")
const rawInput = ref("") const rawInput = ref("")
const [loading, toggleLoading] = useToggle()
const users = shallowRef<string[][]>([])
function generateUsers() { const classError = computed(() => {
if (!rawInput.value || !rawInput.value.trim()) { const value = classNo.value.trim()
message.info("请填写相关内容") if (!value) return ""
return false const n = Number(value)
if (!/^\d+$/.test(value) || n < CLASS_NAME_MIN_VALUE || n > CLASS_NAME_MAX_VALUE) {
return `班级号要是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`
} }
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入, return ""
// 这里先拦一道,省得填完一屏用户名才被打回来 })
if ( const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ""))
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE) /** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
) { const names = computed(() =>
message.error( rawInput.value
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`, .split("\n")
) .map((line) => line.replace(/\s+/g, ""))
return false .filter(Boolean),
)
// 每个名字的密码记住,边改名单边看时不会每敲一个字全换一遍
const passwords = new Map<string, string>()
function passwordOf(name: string) {
let value = passwords.get(name)
if (!value) {
value = ""
for (let j = 0; j < 6; j++) value += "123456789".charAt(Math.floor(Math.random() * 9))
passwords.set(name, value)
} }
let className = prefix.value ? `ks${prefix.value}` : "" return value
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重,而且库里同一个邮箱 }
// 出现两次的话,那两个账号在「编辑用户」里保存一次就撞 409,从此改不动。
// 原来只按「班级 + 批内序号」拼,同一个班分两批导入必然重号 —— 加一段每批随机的 const rows = computed(() =>
// 后缀错开。这个地址不发给任何人,纯占位。 names.value.map((name) => ({
const batch = Math.random().toString(36).slice(2, 8) name,
rawInput.value = rawInput.value.trim() username: prefix.value + name,
const inputs = rawInput.value.split("\n") password: passwordOf(prefix.value + name),
users.value = inputs.map((u, i) => { })),
const username = className + u )
let password = ""
for (let j = 0; j < 6; j++) { /** 同批重名:「第 4 行和第 2 行重名」 */
password += "123456789".charAt(Math.floor(Math.random() * 9)) const duplicates = computed(() => {
} const first = new Map<string, number>()
const realName = u const result: { line: number; firstLine: number; name: string }[] = []
const email = `${className || "user"}.${batch}.${i + 1}@example.com` rows.value.forEach((row, i) => {
return [username, password, email, realName] const key = row.username.toLowerCase()
const seen = first.get(key)
if (seen === undefined) first.set(key, i + 1)
else result.push({ line: i + 1, firstLine: seen, name: row.name })
}) })
return true return result
} })
async function uploadUsers() { // 已经有的账号:停手半秒再去查
try { const existing = ref<{ username: string; createTime: string | null }[]>([])
toggleLoading(true) const checking = ref(false)
await importUsers(users.value) watchDebounced(
message.success("用户已上传成功") () => rows.value.map((row) => row.username).join("\n"),
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、 async () => {
// 真名本来就是老师粘进来的那一列,都不用回传 const usernames = [...new Set(rows.value.map((row) => row.username))]
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n") if (!usernames.length || classError.value || usernames.length > 500) {
const hiddenElement = document.createElement("a") existing.value = []
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv) return
hiddenElement.target = "_blank" }
hiddenElement.download = prefix.value + ".csv" checking.value = true
hiddenElement.click() try {
hiddenElement.remove() existing.value = (await checkUsernames(usernames)).existing
} catch (err) { } catch {
message.error("上传失败:" + errorMessage(err)) existing.value = []
} finally { } finally {
toggleLoading(false) checking.value = false
} }
} },
{ debounce: 500, immediate: true },
)
const tooMany = computed(() => rows.value.length > 500)
const issueCount = computed(
() =>
(classError.value ? 1 : 0) +
duplicates.value.length +
existing.value.length +
(tooMany.value ? 1 : 0),
)
const canSubmit = computed(
() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value,
)
// ---------------------------------------------------------------- 建号
const saving = ref(false)
const done = ref<{ username: string; password: string }[] | null>(null)
const addedClasses = ref<string[]>([])
async function submit() { async function submit() {
const ok = generateUsers() if (!canSubmit.value) return
if (ok) { // 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重。每批一段随机后缀错开,
uploadUsers() // 同一个班分两批导也不会撞。这个地址不发给任何人,纯占位
const batch = Math.random().toString(36).slice(2, 8)
const payload = rows.value.map((row, i) => [
row.username,
row.password,
`${prefix.value || "user"}.${batch}.${i + 1}@example.com`,
row.name,
])
saving.value = true
try {
const res = await importUsers(payload)
addedClasses.value = res.addedClasses ?? []
done.value = rows.value.map(({ username, password }) => ({ username, password }))
} catch (err) {
message.error("没建成:" + errorMessage(err))
} finally {
saving.value = false
} }
} }
function again() {
done.value = null
rawInput.value = ""
addedClasses.value = []
passwords.clear()
}
/** 下载名单:用户名、密码两列。开头带 BOM,Excel 打开中文才不乱码 */
function download() {
if (!done.value) return
const csv = "用户名,密码\n" + done.value.map((it) => `${it.username},${it.password}`).join("\n")
const link = document.createElement("a")
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
link.download = `${prefix.value || "账号"}.csv`
link.click()
URL.revokeObjectURL(link.href)
}
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
function printSlips() {
if (!done.value) return
const escape = (text: string) =>
text.replace(
/[&<>"]/g,
(ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]!,
)
const slips = done.value
.map(
(it) =>
`<div class="slip"><div class="u">${escape(it.username)}</div><div class="p">密码 ${escape(it.password)}</div></div>`,
)
.join("")
const win = window.open("", "_blank")
if (!win) {
message.warning("浏览器拦住了新窗口,允许弹出窗口后再点一次")
return
}
win.document.write(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escape(
classLabel(classNo.value.trim()) || "账号条",
)}</title><style>
body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:0}
.slip{border:1px dashed #999;padding:10px 12px;break-inside:avoid}
.u{font-size:16px;font-weight:600}.p{font-family:Consolas,monospace;font-size:16px;margin-top:4px}
</style></head><body><div class="grid">${slips}</div></body></html>`)
win.document.close()
win.focus()
win.print()
}
</script> </script>
<template> <template>
<PageHeader <PageHeader title="批量建号" :back="{ name: 'admin user list' }" />
title="批量导入用户"
description="每行一个名字,用户名为 ks + 班级号 + 名字,密码随机生成;导入后会下载账号清单" <!-- 建好了 -->
:back="{ name: 'admin user list' }" <div v-if="done" class="done">
/> <div class="done-bar">
<n-space> <b>建好了 {{ done.length }} 个账号</b>
<n-flex vertical> <n-text v-if="addedClasses.length" depth="3">
<n-flex align="center"> {{ addedClasses.map((name) => classLabel(name)).join("、") }}已加进班级列表
<div style="width: 18px; font-size: 1.2rem">ks</div> </n-text>
<n-input-number <div class="spacer"></div>
style="width: 170px" <n-button @click="download">下载名单(表格)</n-button>
v-model:value="prefix" <n-button type="primary" @click="printSlips">打印账号条</n-button>
clearable <n-button @click="again">再建一批</n-button>
:max="CLASS_NAME_MAX_VALUE" </div>
:min="0" <div class="slips">
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`" <div v-for="it in done" :key="it.username" class="slip">
<UserName :username="it.username" />
<div class="mono">密码 {{ it.password }}</div>
</div>
</div>
</div>
<div v-else class="layout">
<div class="left">
<label class="label" for="class-no">班级号</label>
<div class="class-row">
<span class="ks">ks</span>
<n-input
v-model:value="classNo"
:input-props="{ id: 'class-no', inputmode: 'numeric' }"
placeholder="如 2612"
style="width: 120px"
/> />
</n-flex> <span v-if="classNo.trim() && !classError" class="class-name">
{{ classLabel(classNo.trim()) }}
</span>
</div>
<label class="label" for="names">
名单 <n-text depth="3" class="hint">从表格里整列复制过来,一行一个</n-text>
</label>
<n-input <n-input
type="textarea"
class="inputArea"
placeholder="每行一个用户名"
v-model:value="rawInput" v-model:value="rawInput"
type="textarea"
:input-props="{ id: 'names' }"
:rows="20"
placeholder="一行一个名字"
/> />
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button> </div>
</n-flex>
</n-space> <div class="right">
<template v-if="rows.length">
<div class="head">
<b>会建这 {{ rows.length }} 个账号</b>
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round>
没有重名
</n-tag>
<n-tag v-if="issueCount" type="warning" :bordered="false" round>
有 {{ issueCount }} 处要先改
</n-tag>
</div>
<div v-if="issueCount" class="issues">
<div v-if="classError">{{ classError }}</div>
<div v-if="tooMany">一次最多 500 个,分两批建</div>
<div v-for="dup in duplicates" :key="dup.line">
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 ——
同班同名的,名字后面加个 1、2 区分
</div>
<div v-for="it in existing" :key="it.username">
{{ it.username }} 已经有这个账号了<template v-if="it.createTime"
>({{ parseTime(it.createTime, "YYYY-MM-DD") }} 建的)</template
>
—— 删掉这一行,或者换个名字
</div>
</div>
<div class="preview">
<table>
<thead>
<tr>
<th></th>
<th>用户名</th>
<th>密码</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td class="n">{{ i + 1 }}</td>
<td><UserName :username="row.username" /></td>
<td class="mono">{{ row.password }}</td>
</tr>
</tbody>
</table>
</div>
<div>
<n-button
type="primary"
size="large"
:disabled="!canSubmit"
:loading="saving || checking"
@click="submit"
>
建好这 {{ rows.length }} 个账号
</n-button>
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px">
先填班级号
</n-text>
</div>
</template>
<n-empty v-else description="左边粘进名单,这里就列出会建成的账号" style="margin-top: 80px" />
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.inputArea { .layout {
width: 200px; display: flex;
height: 500px; gap: 24px;
align-items: flex-start;
}
.left {
width: 330px;
flex: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.label {
font-size: 14px;
font-weight: 600;
}
.hint {
font-size: 13px;
font-weight: 400;
}
.class-row {
display: flex;
align-items: center;
gap: 8px;
}
.ks {
font-size: 18px;
opacity: 0.6;
}
.class-name {
font-size: 15px;
}
.right {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.issues {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid rgba(240, 160, 32, 0.45);
background: rgba(240, 160, 32, 0.08);
font-size: 14px;
}
.preview {
max-height: 520px;
overflow: auto;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 4px 12px;
}
.preview table {
border-collapse: collapse;
width: 100%;
font-size: 14px;
}
.preview th {
font-weight: 400;
font-size: 13px;
opacity: 0.6;
text-align: left;
padding: 8px;
}
.preview td {
height: 38px;
padding: 0 8px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
white-space: nowrap;
}
.preview .n {
width: 32px;
opacity: 0.5;
font-variant-numeric: tabular-nums;
}
.mono {
font-family: Consolas, Monaco, monospace;
font-variant-numeric: tabular-nums;
}
.done {
display: flex;
flex-direction: column;
gap: 16px;
}
.done-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid rgba(24, 160, 88, 0.35);
background: rgba(24, 160, 88, 0.06);
white-space: nowrap;
}
.done-bar b {
font-size: 16px;
}
.spacer {
flex: 1 1 auto;
}
.slips {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.slip {
border: 1px dashed rgba(128, 128, 128, 0.5);
border-radius: 4px;
padding: 8px 10px;
font-size: 14px;
line-height: 22px;
} }
</style> </style>
+118 -43
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui" import { DataTableRowKey, SelectOption, useThemeVars } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
@@ -12,14 +12,24 @@ import UserNameCell from "./components/UserNameCell.vue"
import PasswordCell from "./components/PasswordCell.vue" import PasswordCell from "./components/PasswordCell.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants" import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import TextCopy from "shared/components/TextCopy.vue" import { NO_CLASS } from "@oj2/contract"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { useConfigStore } from "shared/store/config"
/**
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
* 看一个班的名单、开学建号。所以加了班级筛选,一行只摆 用户名 / 班级 / 密码 / 上次登录 / 建号,
* 「重置密码」常驻、其余收进 ⋯。ID、真名(名字就在用户名里)、占位邮箱不再单列,搜索照样搜得到
*/
const message = useMessage() const message = useMessage()
const theme = useThemeVars()
const configStore = useConfigStore()
interface UserQuery { interface UserQuery {
keyword: string keyword: string
type: string type: string
orderBy: string orderBy: string
className: string
} }
// 使用分页 composable // 使用分页 composable
@@ -27,21 +37,31 @@ const { query, clearQuery } = usePagination<UserQuery>({
keyword: useRouteQuery("keyword", "").value, keyword: useRouteQuery("keyword", "").value,
type: useRouteQuery("type", "").value, type: useRouteQuery("type", "").value,
orderBy: useRouteQuery("orderBy", "").value, orderBy: useRouteQuery("orderBy", "").value,
className: useRouteQuery("className", "").value,
}) })
const classOptions = computed(() => [
{ label: "全部班级", value: "" },
...groupClassOptions(configStore.config?.classList ?? []),
{ label: classLabel(NO_CLASS), value: NO_CLASS },
])
const neverLoggedIn = ref(0)
const total = ref(0) const total = ref(0)
const users = ref<AdminUserForm[]>([]) const users = ref<AdminUserForm[]>([])
const userEditing = ref<AdminUserForm | null>(null) const userEditing = ref<AdminUserForm | null>(null)
const adminOptions = [ const adminOptions = [
{ label: "全部用户", value: "" }, { label: "全部角色", value: "" },
{ label: "学生", value: USER_TYPE.REGULAR_USER },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN }, { label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN }, { label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN }, { label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
] ]
const sortOptions = [ const sortOptions = [
{ label: "默认排序", value: "" }, { label: "最近建号", value: "" },
{ label: "按用户名", value: "username" },
{ label: "最近登录", value: "-last_login" }, { label: "最近登录", value: "-last_login" },
{ label: "在线优先", value: "-online" }, { label: "在线优先", value: "-online" },
] ]
@@ -54,20 +74,41 @@ const userIDs = ref<DataTableRowKey[]>([])
const rowKey = (row: AdminUserForm) => row.id const rowKey = (row: AdminUserForm) => row.id
/** 上次登录:今天的写「今天 13:02」,在线的前面一个绿点;今年的写到月日;从没登录的写出来 */
function lastLoginText(row: AdminUserForm) {
if (!row.lastLogin) return h("span", { class: "muted" }, "从没登录")
const day = parseTime(row.lastLogin, "YYYY-MM-DD")
const today = parseTime(new Date().toISOString(), "YYYY-MM-DD")
const text =
day === today
? `今天 ${parseTime(row.lastLogin, "HH:mm")}`
: day.slice(0, 4) === today.slice(0, 4)
? parseTime(row.lastLogin, "MM-DD HH:mm")
: day
return h("span", { class: "num", title: parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") }, [
row.isOnline ? h("span", { class: "online", title: "在线" }) : null,
text,
])
}
const columns: DataTableColumn<AdminUserForm>[] = [ const columns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" }, { type: "selection" },
{ title: "ID", key: "id", width: 80 },
{ {
title: "用户名", title: "用户名",
key: "username", key: "username",
width: 220, minWidth: 200,
render: (row) => h(UserNameCell, { user: row }), render: (row) => h(UserNameCell, { user: row }),
}, },
{ {
// 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码 title: "班级",
key: "className",
width: 130,
render: (row) => h("span", { class: "muted" }, row.className ? classLabel(row.className) : "—"),
},
{
title: "密码", title: "密码",
key: "raw_password", key: "raw_password",
width: 180, width: 150,
render: (row) => render: (row) =>
h(PasswordCell, { h(PasswordCell, {
user: row, user: row,
@@ -77,36 +118,22 @@ const columns: DataTableColumn<AdminUserForm>[] = [
}, },
}), }),
}, },
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
{ {
title: "创建时间", title: "建号",
key: "create_time", key: "create_time",
width: 200, width: 110,
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) => render: (row) =>
h(NFlex, { align: "center", size: "small" }, () => [ h(
row.isOnline ? h(NTag, { type: "success", size: "small" }, () => "在线") : null, "span",
row.lastLogin ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") : "从未登录", { class: "num muted" },
]), row.createTime ? parseTime(row.createTime, "YYYY-MM-DD") : "",
),
}, },
{
title: "真名",
key: "real_name",
width: 100,
render: (row) => h(TextCopy, () => row.realName),
},
{ title: "邮箱", key: "email", width: 260 },
{ {
key: "actions", key: "actions",
title: "选项", title: "",
width: 280, width: 130,
fixed: "right",
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
user: row, user: row,
@@ -136,8 +163,16 @@ 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
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
const res = await getUserList(offset, query.limit, query.type, query.keyword, query.orderBy) const res = await getUserList(
offset,
query.limit,
query.type,
query.keyword,
query.orderBy,
query.className,
)
total.value = res.total total.value = res.total
neverLoggedIn.value = res.neverLoggedIn
users.value = res.results users.value = res.results
revealedPasswords.value.clear() revealedPasswords.value.clear()
} }
@@ -146,8 +181,14 @@ function chooseUsers(rowKeys: DataTableRowKey[]) {
userIDs.value = rowKeys userIDs.value = rowKeys
} }
async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) { async function onDeleteUsers(ids: DataTableRowKey[] | Ref<number[]>) {
await deleteUsers(toRaw(userIDs) as number[]) try {
await deleteUsers(toRaw(ids) as number[])
message.success("删掉了")
userIDs.value = []
} catch (err) {
message.error(errorMessage(err))
}
listUsers() listUsers()
} }
@@ -252,7 +293,7 @@ onMounted(listUsers)
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 }) watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
// 监听其他查询条件变化 // 监听其他查询条件变化
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers) watch(() => [query.page, query.limit, query.type, query.orderBy, query.className], listUsers)
</script> </script>
<template> <template>
@@ -262,38 +303,51 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<template #trigger> <template #trigger>
<n-button type="error" secondary>删除选中({{ userIDs.length }})</n-button> <n-button type="error" secondary>删除选中({{ userIDs.length }})</n-button>
</template> </template>
确定删除选中的用户吗?删除后无法恢复! 删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。
</n-popconfirm> </n-popconfirm>
<n-button @click="$router.push({ name: 'admin user generate' })">批量导入</n-button> <n-button @click="createNewUser">新建一个</n-button>
<n-button type="primary" @click="createNewUser">新建用户</n-button> <n-button type="primary" @click="$router.push({ name: 'admin user generate' })">
批量建号
</n-button>
</template> </template>
<template #filters> <template #filters>
<n-input <n-input
style="width: 220px" style="width: 220px"
v-model:value="query.keyword" v-model:value="query.keyword"
placeholder="用户名 / 真名 / 邮箱" placeholder="用户名或名字"
clearable clearable
@clear="clearQuery" @clear="clearQuery"
/> />
<n-select
v-model:value="query.className"
:options="classOptions"
filterable
aria-label="班级"
style="width: 150px"
/>
<n-select <n-select
v-model:value="query.type" v-model:value="query.type"
:options="adminOptions" :options="adminOptions"
placeholder="选择用户类型" aria-label="角色"
style="width: 120px" style="width: 120px"
/> />
<n-select <n-select
v-model:value="query.orderBy" v-model:value="query.orderBy"
:options="sortOptions" :options="sortOptions"
placeholder="排序方式" aria-label="排序"
style="width: 120px" style="width: 120px"
/> />
<n-text depth="3">
{{ total }} 人<template v-if="neverLoggedIn"> · {{ neverLoggedIn }} 人从没登录</template>
</n-text>
</template> </template>
</PageHeader> </PageHeader>
<n-data-table <n-data-table
class="user-table"
:data="users" :data="users"
:columns="columns" :columns="columns"
striped
:row-key="rowKey" :row-key="rowKey"
:checked-row-keys="userIDs"
@update:checked-row-keys="chooseUsers" @update:checked-row-keys="chooseUsers"
/> />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
@@ -357,3 +411,24 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
<style scoped>
.user-table :deep(.muted) {
color: v-bind("theme.textColor3");
}
.user-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.user-table :deep(.online) {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: v-bind("theme.successColor");
vertical-align: 1px;
}
</style>