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,
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除用户
|
// 批量删除用户
|
||||||
|
|||||||
@@ -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 size="small" quaternary aria-label="更多">
|
||||||
|
<template #icon><Icon icon="ph:dots-three-bold" /></template>
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button size="small" type="primary" secondary @click="$emit('openEditModal', props.user)">
|
</n-dropdown>
|
||||||
编辑
|
|
||||||
</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>
|
</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>
|
||||||
@@ -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"
|
|
||||||
hiddenElement.click()
|
|
||||||
hiddenElement.remove()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("上传失败:" + errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
toggleLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
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() {
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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" />
|
||||||
</n-flex>
|
<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
|
<n-input
|
||||||
type="textarea"
|
v-model:value="classNo"
|
||||||
class="inputArea"
|
:input-props="{ id: 'class-no', inputmode: 'numeric' }"
|
||||||
placeholder="每行一个用户名"
|
placeholder="如 2612"
|
||||||
v-model:value="rawInput"
|
style="width: 120px"
|
||||||
/>
|
/>
|
||||||
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button>
|
<span v-if="classNo.trim() && !classError" class="class-name">
|
||||||
</n-flex>
|
{{ classLabel(classNo.trim()) }}
|
||||||
</n-space>
|
</span>
|
||||||
|
</div>
|
||||||
|
<label class="label" for="names">
|
||||||
|
名单 <n-text depth="3" class="hint">从表格里整列复制过来,一行一个</n-text>
|
||||||
|
</label>
|
||||||
|
<n-input
|
||||||
|
v-model:value="rawInput"
|
||||||
|
type="textarea"
|
||||||
|
:input-props="{ id: 'names' }"
|
||||||
|
:rows="20"
|
||||||
|
placeholder="一行一个名字"
|
||||||
|
/>
|
||||||
|
</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>
|
</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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user