feat(后台用户): 用户列表按班级看、「重置密码」常驻;批量建号粘进名单当场列出会建成的账号,重名和已有的先点出来,建好能下载名单、打印账号条
用户列表去掉 ID、真名、占位邮箱和四颗彩色按钮。下载的名单开头带 BOM,Excel 打开中文不再乱码。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba823ecb7d
commit
5561e0bc3a
5 files changed
+607
-158
No files matched your search
@@ -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 })
|
||||
}
|
||||
|
||||
// 批量删除用户
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user