第 2 阶段:账号与班级,作品保存在服务器
- SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品 - 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数 - 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8, 识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码, 导出带密码的名单 - 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存 - 前端加路由:登录页、实验页、班级管理页 - 部署:TEACHER_PASSWORD 必填,数据库放在数据卷 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a00622be15
commit
7cec273d58
31 files changed
+1776
-382
No files matched your search
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生。
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
|
||||
|
||||
interface ClassInfo {
|
||||
id: number
|
||||
name: string
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
interface Student {
|
||||
id: number
|
||||
username: string
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
created: number
|
||||
updated: number
|
||||
errors: { row: number; message: string }[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const classes = ref<ClassInfo[]>([])
|
||||
const selectedId = ref<number>()
|
||||
const selected = computed(() => classes.value.find((c) => c.id === selectedId.value))
|
||||
const students = ref<Student[]>([])
|
||||
|
||||
/** 出错时弹出服务器给的说明 */
|
||||
async function attempt<T>(action: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await action()
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClasses() {
|
||||
classes.value = (await attempt(() => api<ClassInfo[]>('GET', '/api/teacher/classes'))) ?? []
|
||||
if (!selected.value) selectedId.value = classes.value[0]?.id
|
||||
}
|
||||
|
||||
async function loadStudents() {
|
||||
students.value = selectedId.value
|
||||
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
|
||||
: []
|
||||
}
|
||||
|
||||
async function selectClass(id: number) {
|
||||
selectedId.value = id
|
||||
await loadStudents()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClasses()
|
||||
await loadStudents()
|
||||
})
|
||||
|
||||
// ---------- 班级 ----------
|
||||
const newClassName = ref('')
|
||||
const newClassOpen = ref(false)
|
||||
|
||||
async function createClass() {
|
||||
const created = await attempt(() => api<{ id: number }>('POST', '/api/teacher/classes', { name: newClassName.value }))
|
||||
if (!created) return
|
||||
newClassName.value = ''
|
||||
newClassOpen.value = false
|
||||
await loadClasses()
|
||||
await selectClass(created.id)
|
||||
}
|
||||
|
||||
const renameValue = ref('')
|
||||
const renameOpen = ref(false)
|
||||
|
||||
async function renameClass() {
|
||||
if (!selected.value) return
|
||||
const ok = await attempt(() => api('PATCH', `/api/teacher/classes/${selected.value!.id}`, { name: renameValue.value }))
|
||||
if (!ok) return
|
||||
renameOpen.value = false
|
||||
await loadClasses()
|
||||
}
|
||||
|
||||
async function deleteClass() {
|
||||
if (!selected.value) return
|
||||
const ok = await attempt(() => api('DELETE', `/api/teacher/classes/${selected.value!.id}`))
|
||||
if (!ok) return
|
||||
selectedId.value = undefined
|
||||
await loadClasses()
|
||||
await loadStudents()
|
||||
}
|
||||
|
||||
// ---------- 学生 ----------
|
||||
async function resetPassword(student: Student) {
|
||||
const result = await attempt(() => api<{ password: string }>('POST', `/api/teacher/students/${student.id}/reset-password`))
|
||||
if (!result) return
|
||||
message.success(`${student.name} 的新密码:${result.password}`)
|
||||
await loadStudents()
|
||||
}
|
||||
|
||||
function deleteStudent(student: Student) {
|
||||
dialog.warning({
|
||||
title: '删除学生',
|
||||
content: `将删除 ${student.name}(${student.username})和他保存的所有作品,不能恢复。确定删除吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await attempt(() => api('DELETE', `/api/teacher/students/${student.id}`))
|
||||
await loadClasses()
|
||||
await loadStudents()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<Student> = [
|
||||
{ title: '账号', key: 'username', sorter: 'default' },
|
||||
{ title: '姓名', key: 'name' },
|
||||
{ title: '密码', key: 'password', render: (s) => h('code', s.password) },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (s) =>
|
||||
h('div', { class: 'row-actions' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => resetPassword(s) }, () => '重置密码'),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteStudent(s) }, () => '删除'),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
// ---------- 导入、导出名单 ----------
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const preview = ref<RosterRow[] | null>(null)
|
||||
const previewValid = computed(() => preview.value?.filter((r) => !r.problem) ?? [])
|
||||
const importing = ref(false)
|
||||
|
||||
async function onFileChosen(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
const rows = toRoster(parseCsv(decodeCsv(await file.arrayBuffer())))
|
||||
if (rows.length === 0) {
|
||||
message.warning('文件里没有读到学生')
|
||||
return
|
||||
}
|
||||
preview.value = rows
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!selected.value || previewValid.value.length === 0) return
|
||||
importing.value = true
|
||||
const rows = previewValid.value.map(({ username, name, password }) => ({ username, name, password }))
|
||||
const result = await attempt(() => api<ImportResult>('POST', `/api/teacher/classes/${selected.value!.id}/import`, { rows }))
|
||||
importing.value = false
|
||||
if (!result) return
|
||||
preview.value = null
|
||||
await loadClasses()
|
||||
await loadStudents()
|
||||
const summary = `新增 ${result.created} 人,更新 ${result.updated} 人`
|
||||
if (result.errors.length === 0) message.success(summary)
|
||||
else
|
||||
dialog.warning({
|
||||
title: '部分学生没有导入',
|
||||
content: () =>
|
||||
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
|
||||
positiveText: '知道了',
|
||||
})
|
||||
}
|
||||
|
||||
/** 下载 CSV:带 BOM,Excel 打开时中文不乱码;账号前加制表符,长学号不会变成科学计数法 */
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const quote = (cell: string) => (/[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell)
|
||||
const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n'
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }))
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
function exportRoster() {
|
||||
if (!selected.value) return
|
||||
downloadCsv(`${selected.value.name}-名单.csv`, [
|
||||
['学号', '姓名', '密码'],
|
||||
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
|
||||
])
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
downloadCsv('学生名单模板.csv', [
|
||||
['学号', '姓名', '密码'],
|
||||
['\t2024001', '张三', ''],
|
||||
])
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<span class="title">班级管理</span>
|
||||
<RouterLink to="/" class="nav-link">返回实验</RouterLink>
|
||||
<span class="spacer" />
|
||||
<span class="user">{{ currentUser?.name }}</span>
|
||||
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<aside class="classes">
|
||||
<div class="classes-head">
|
||||
<span>班级</span>
|
||||
<NPopover v-model:show="newClassOpen" trigger="click" placement="bottom-end">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>+ 新建</NButton>
|
||||
</template>
|
||||
<form class="popover-form" @submit.prevent="createClass">
|
||||
<NInput v-model:value="newClassName" size="small" placeholder="班级名,如 物联网 2401" autofocus />
|
||||
<NButton size="small" type="primary" attr-type="submit" :disabled="!newClassName.trim()">创建</NButton>
|
||||
</form>
|
||||
</NPopover>
|
||||
</div>
|
||||
<button
|
||||
v-for="c in classes"
|
||||
:key="c.id"
|
||||
class="class-item"
|
||||
:class="{ active: c.id === selectedId }"
|
||||
@click="selectClass(c.id)"
|
||||
>
|
||||
<span>{{ c.name }}</span>
|
||||
<span class="count">{{ c.studentCount }} 人</span>
|
||||
</button>
|
||||
<NEmpty v-if="classes.length === 0" description="还没有班级,先新建一个" size="small" class="empty" />
|
||||
</aside>
|
||||
|
||||
<section v-if="selected" class="students">
|
||||
<div class="students-head">
|
||||
<h2>{{ selected.name }}</h2>
|
||||
<NPopover v-model:show="renameOpen" trigger="click" @update:show="(v: boolean) => v && (renameValue = selected!.name)">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>改名</NButton>
|
||||
</template>
|
||||
<form class="popover-form" @submit.prevent="renameClass">
|
||||
<NInput v-model:value="renameValue" size="small" autofocus />
|
||||
<NButton size="small" type="primary" attr-type="submit" :disabled="!renameValue.trim()">保存</NButton>
|
||||
</form>
|
||||
</NPopover>
|
||||
<NPopconfirm positive-text="删除" negative-text="取消" @positive-click="deleteClass">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>删除班级</NButton>
|
||||
</template>
|
||||
只能删除没有学生的班级。确定删除"{{ selected.name }}"吗?
|
||||
</NPopconfirm>
|
||||
<span class="spacer" />
|
||||
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
|
||||
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
|
||||
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
|
||||
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
||||
</div>
|
||||
<p class="tip">
|
||||
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||
</p>
|
||||
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
||||
</section>
|
||||
<section v-else class="students">
|
||||
<NEmpty description="选择或新建一个班级" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<NModal
|
||||
:show="preview !== null"
|
||||
preset="card"
|
||||
:title="`导入到 ${selected?.name ?? ''}`"
|
||||
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||
@update:show="(v: boolean) => !v && (preview = null)"
|
||||
>
|
||||
<p>
|
||||
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
|
||||
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
|
||||
>。
|
||||
</p>
|
||||
<div class="preview">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
|
||||
<td>{{ r.username }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.password || '(自动生成)' }}</td>
|
||||
<td class="problem">{{ r.problem }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="modal-actions">
|
||||
<NButton @click="preview = null">取消</NButton>
|
||||
<NButton type="primary" :loading="importing" :disabled="previewValid.length === 0" @click="confirmImport">
|
||||
导入 {{ previewValid.length }} 人
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.classes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.classes-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px 6px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.class-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.class-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.class-item.active {
|
||||
background: #e0ecff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.students {
|
||||
padding: 12px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.students-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.students-head h2 {
|
||||
margin: 0 4px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.popover-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
:deep(.row-actions) {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview th,
|
||||
.preview td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview td.problem {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.preview tr.bad td {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.problem {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user