Deploy / deploy (push) Canceled after 0s
- 侧栏按 概览 / 教学内容 / 学情分析 / 站点管理 分组,去掉超管菜单里的重复项; 菜单项的角色与路由守卫一一对应,没有题目权限的教师不再看到点了就被踢回首页的「题目」 - 顶栏:面包屑、回到前台、明暗切换、用户菜单(退出登录);窄屏侧栏自动收起 - 新增 PageHeader:标题 + 右侧主操作 + 下方筛选栏,编辑页带返回按钮 - 卡点分析 / 年度趋势 / 标签管理从题目列表的按钮挪进侧栏 - 首页改成统计卡片 + 班级排名卡片;设置页按钮移到卡片右上角 - 题目、比赛、题单、用户列表的「选项」列固定在右侧,不再被挤出屏幕 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
3.8 KiB
Vue
120 lines
3.8 KiB
Vue
<script setup lang="ts">
|
|
import PageHeader from "admin/components/PageHeader.vue"
|
|
import { errorMessage } from "utils/api"
|
|
import {
|
|
CLASS_NAME_MAX_DIGITS,
|
|
CLASS_NAME_MAX_VALUE,
|
|
CLASS_NAME_MIN_DIGITS,
|
|
CLASS_NAME_MIN_VALUE,
|
|
} from "utils/constants"
|
|
import { importUsers } from "../api"
|
|
|
|
const message = useMessage()
|
|
const prefix = ref(0)
|
|
const rawInput = ref("")
|
|
const [loading, toggleLoading] = useToggle()
|
|
const users = shallowRef<string[][]>([])
|
|
|
|
function generateUsers() {
|
|
if (!rawInput.value || !rawInput.value.trim()) {
|
|
message.info("请填写相关内容")
|
|
return false
|
|
}
|
|
// 后端 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
|
|
}
|
|
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 true
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
const ok = generateUsers()
|
|
if (ok) {
|
|
uploadUsers()
|
|
}
|
|
}
|
|
</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} 位)`"
|
|
/>
|
|
</n-flex>
|
|
<n-input
|
|
type="textarea"
|
|
class="inputArea"
|
|
placeholder="每行一个用户名"
|
|
v-model:value="rawInput"
|
|
/>
|
|
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button>
|
|
</n-flex>
|
|
</n-space>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.inputArea {
|
|
width: 200px;
|
|
height: 500px;
|
|
}
|
|
</style>
|