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,
GenerateTestInputsResponse,
TestCaseFiles,
CheckUsernamesResponse,
} from "@oj2/contract"
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", {
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
// "-online" 是新增的,原样透传
@@ -192,10 +201,16 @@ export function getUserList(offset = 0, limit = 10, type = "", keyword: string,
keyword,
type,
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) {
return api.put<AdminUser>(`admin/users/${user.id}`, {
@@ -216,8 +231,9 @@ export async function resetPassword(userID: number) {
}
// 导入用户
/** 批量建号。addedClasses:这批里的新班级,后端已经顺手加进了班级列表 */
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>
import { Icon } from "@iconify/vue"
import { editUser } from "admin/api"
import type { AdminUserForm } from "utils/types"
/**
* 用户列表一行的操作(设计稿「后台用户与批量建号」UsList):最常用的「重置密码」常驻
* (学生忘了密码是后台进得最多的一件事),其余收进 ⋯。原来四颗彩色按钮并排,
* 1366 的屏上把「上次登录」挤成了半截
*/
interface Props {
user: AdminUserForm
}
@@ -13,33 +19,42 @@ const emit = defineEmits<{
(e: "resetPassword", value: AdminUserForm): void
}>()
const dialog = useDialog()
async function banUser() {
props.user.isDisabled = !props.user.isDisabled
await editUser(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>
<template>
<n-flex>
<n-button size="small" type="error" secondary @click="$emit('resetPassword', props.user)">
重置密码
</n-button>
<n-button size="small" type="primary" secondary @click="$emit('openEditModal', props.user)">
编辑
</n-button>
<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 :size="4" justify="end" :wrap="false">
<n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
<n-button size="small" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</n-flex>
</template>
@@ -11,20 +11,27 @@ const props = defineProps<Props>()
const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER)
</script>
<template>
<n-flex align="center">
<n-tag v-if="props.user.isDisabled" type="error" size="small"> 封号中 </n-tag>
<n-tag v-if="hasRoleTag" :type="getUserRole(props.user.adminType).type" size="small">
<!-- 用户名在前(老师要认的是它),角色、禁用这些标签跟在后面 -->
<n-flex align="center" :size="6" :wrap="false">
<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 }}
</n-tag>
<n-tag
size="small"
v-if="
props.user.adminType === USER_TYPE.STUDENT_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>
<TextCopy>{{ props.user.username }}</TextCopy>
</n-flex>
</template>
+422 -86
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
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 {
CLASS_NAME_MAX_DIGITS,
@@ -7,113 +9,447 @@ import {
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} 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 prefix = ref(0)
const classNo = ref("")
const rawInput = ref("")
const [loading, toggleLoading] = useToggle()
const users = shallowRef<string[][]>([])
function generateUsers() {
if (!rawInput.value || !rawInput.value.trim()) {
message.info("请填写相关内容")
return false
const classError = computed(() => {
const value = classNo.value.trim()
if (!value) return ""
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 只认合法位数的班级号,位数不对会整批拒绝导入,
// 这里先拦一道,省得填完一屏用户名才被打回来
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
return ""
})
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ""))
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
const names = computed(() =>
rawInput.value
.split("\n")
.map((line) => line.replace(/\s+/g, ""))
.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}` : ""
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重,而且库里同一个邮箱
// 出现两次的话,那两个账号在「编辑用户」里保存一次就撞 409,从此改不动。
// 原来只按「班级 + 批内序号」拼,同一个班分两批导入必然重号 —— 加一段每批随机的
// 后缀错开。这个地址不发给任何人,纯占位。
const batch = Math.random().toString(36).slice(2, 8)
rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n")
users.value = inputs.map((u, i) => {
const username = className + u
let password = ""
for (let j = 0; j < 6; j++) {
password += "123456789".charAt(Math.floor(Math.random() * 9))
}
const realName = u
const email = `${className || "user"}.${batch}.${i + 1}@example.com`
return [username, password, email, realName]
return value
}
const rows = computed(() =>
names.value.map((name) => ({
name,
username: prefix.value + name,
password: passwordOf(prefix.value + name),
})),
)
/** 同批重名:「第 4 行和第 2 行重名」 */
const duplicates = computed(() => {
const first = new Map<string, number>()
const result: { line: number; firstLine: number; name: string }[] = []
rows.value.forEach((row, i) => {
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 {
toggleLoading(true)
await importUsers(users.value)
message.success("用户已上传成功")
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
// 真名本来就是老师粘进来的那一列,都不用回传
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n")
const hiddenElement = document.createElement("a")
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
hiddenElement.target = "_blank"
hiddenElement.download = prefix.value + ".csv"
hiddenElement.click()
hiddenElement.remove()
} catch (err) {
message.error("上传失败:" + errorMessage(err))
} finally {
toggleLoading(false)
}
}
// 已经有的账号:停手半秒再去查
const existing = ref<{ username: string; createTime: string | null }[]>([])
const checking = ref(false)
watchDebounced(
() => rows.value.map((row) => row.username).join("\n"),
async () => {
const usernames = [...new Set(rows.value.map((row) => row.username))]
if (!usernames.length || classError.value || usernames.length > 500) {
existing.value = []
return
}
checking.value = true
try {
existing.value = (await checkUsernames(usernames)).existing
} catch {
existing.value = []
} finally {
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() {
const ok = generateUsers()
if (ok) {
uploadUsers()
if (!canSubmit.value) return
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重。每批一段随机后缀错开,
// 同一个班分两批导也不会撞。这个地址不发给任何人,纯占位
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>
<template>
<PageHeader
title="批量导入用户"
description="每行一个名字,用户名为 ks + 班级号 + 名字,密码随机生成;导入后会下载账号清单"
:back="{ name: 'admin user list' }"
/>
<n-space>
<n-flex vertical>
<n-flex align="center">
<div style="width: 18px; font-size: 1.2rem">ks</div>
<n-input-number
style="width: 170px"
v-model:value="prefix"
clearable
:max="CLASS_NAME_MAX_VALUE"
:min="0"
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
<PageHeader title="批量建号" :back="{ name: 'admin user list' }" />
<!-- 建好了 -->
<div v-if="done" class="done">
<div class="done-bar">
<b>建好了 {{ done.length }} 个账号</b>
<n-text v-if="addedClasses.length" depth="3">
{{ addedClasses.map((name) => classLabel(name)).join("、") }}已加进班级列表
</n-text>
<div class="spacer"></div>
<n-button @click="download">下载名单(表格)</n-button>
<n-button type="primary" @click="printSlips">打印账号条</n-button>
<n-button @click="again">再建一批</n-button>
</div>
<div class="slips">
<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
type="textarea"
class="inputArea"
placeholder="每行一个用户名"
v-model:value="rawInput"
type="textarea"
:input-props="{ id: 'names' }"
:rows="20"
placeholder="一行一个名字"
/>
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button>
</n-flex>
</n-space>
</div>
<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>
<style scoped>
.inputArea {
width: 200px;
height: 500px;
.layout {
display: flex;
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>
+118 -43
View File
@@ -1,7 +1,7 @@
<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 { DataTableRowKey, SelectOption, useThemeVars } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
@@ -12,14 +12,24 @@ 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"
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 theme = useThemeVars()
const configStore = useConfigStore()
interface UserQuery {
keyword: string
type: string
orderBy: string
className: string
}
// 使用分页 composable
@@ -27,21 +37,31 @@ const { query, clearQuery } = usePagination<UserQuery>({
keyword: useRouteQuery("keyword", "").value,
type: useRouteQuery("type", "").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 users = ref<AdminUserForm[]>([])
const userEditing = ref<AdminUserForm | null>(null)
const adminOptions = [
{ label: "全部用户", value: "" },
{ label: "全部角色", value: "" },
{ 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 sortOptions = [
{ label: "默认排序", value: "" },
{ label: "最近建号", value: "" },
{ label: "按用户名", value: "username" },
{ label: "最近登录", value: "-last_login" },
{ label: "在线优先", value: "-online" },
]
@@ -54,20 +74,41 @@ const userIDs = ref<DataTableRowKey[]>([])
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>[] = [
{ type: "selection" },
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 220,
minWidth: 200,
render: (row) => h(UserNameCell, { user: row }),
},
{
// 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码
title: "班级",
key: "className",
width: 130,
render: (row) => h("span", { class: "muted" }, row.className ? classLabel(row.className) : "—"),
},
{
title: "密码",
key: "raw_password",
width: 180,
width: 150,
render: (row) =>
h(PasswordCell, {
user: row,
@@ -77,36 +118,22 @@ const columns: DataTableColumn<AdminUserForm>[] = [
},
}),
},
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
{
title: "创建时间",
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 天。上次登录时间照旧显示,在线的人前面多一个标记
width: 110,
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") : "从未登录",
]),
h(
"span",
{ 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",
title: "选项",
width: 280,
fixed: "right",
title: "",
width: 130,
render: (row) =>
h(Actions, {
user: row,
@@ -136,8 +163,16 @@ 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)
const res = await getUserList(
offset,
query.limit,
query.type,
query.keyword,
query.orderBy,
query.className,
)
total.value = res.total
neverLoggedIn.value = res.neverLoggedIn
users.value = res.results
revealedPasswords.value.clear()
}
@@ -146,8 +181,14 @@ function chooseUsers(rowKeys: DataTableRowKey[]) {
userIDs.value = rowKeys
}
async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
await deleteUsers(toRaw(userIDs) as number[])
async function onDeleteUsers(ids: DataTableRowKey[] | Ref<number[]>) {
try {
await deleteUsers(toRaw(ids) as number[])
message.success("删掉了")
userIDs.value = []
} catch (err) {
message.error(errorMessage(err))
}
listUsers()
}
@@ -252,7 +293,7 @@ onMounted(listUsers)
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>
<template>
@@ -262,38 +303,51 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<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>
<n-button @click="createNewUser">新建一个</n-button>
<n-button type="primary" @click="$router.push({ name: 'admin user generate' })">
批量建号
</n-button>
</template>
<template #filters>
<n-input
style="width: 220px"
v-model:value="query.keyword"
placeholder="用户名 / 真名 / 邮箱"
placeholder="用户名或名字"
clearable
@clear="clearQuery"
/>
<n-select
v-model:value="query.className"
:options="classOptions"
filterable
aria-label="班级"
style="width: 150px"
/>
<n-select
v-model:value="query.type"
:options="adminOptions"
placeholder="选择用户类型"
aria-label="角色"
style="width: 120px"
/>
<n-select
v-model:value="query.orderBy"
:options="sortOptions"
placeholder="排序方式"
aria-label="排序"
style="width: 120px"
/>
<n-text depth="3">
{{ total }} 人<template v-if="neverLoggedIn"> · {{ neverLoggedIn }} 人从没登录</template>
</n-text>
</template>
</PageHeader>
<n-data-table
class="user-table"
:data="users"
:columns="columns"
striped
:row-key="rowKey"
:checked-row-keys="userIDs"
@update:checked-row-keys="chooseUsers"
/>
<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-modal>
</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>