名单改为用户名、密码两列;去掉姓名前的班级代号,学生用姓名登录;别班已有同名学生时报错,不再转班
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 21:49:50 +08:00
1 parent 61a32e900d
commit e4c53f5e55
11 files changed
+91 -47

No files matched your search

+3 -3
View File
@@ -24,7 +24,7 @@ import { currentUser } from '../auth'
<h2>学生</h2>
<h3>1. 登录</h3>
<p>用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
<p>用老师发给你的用户名和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
<h3>2. 自由练习和做作业</h3>
<ul>
@@ -87,8 +87,8 @@ import { currentUser } from '../auth'
<ul>
<li>右上角<b>班级管理</b> → <b>班级 + 新建</b>,输入班级名。</li>
<li>
<b>学生</b>标签页 → <b>导入名单(CSV)</b>。CSV 三列:学号、姓名、密码(可以点<b>下载名单模板</b>);Excel 另存为 CSV 即可,GBK、UTF-8 都行。
密码留空时自动生成 6 位数字。
<b>学生</b>标签页 → <b>导入名单(CSV)</b>。CSV 两列:用户名、密码(可以点<b>下载名单模板</b>),用户名就是学生姓名,前面带的班级代号(如
ks241)会自动去掉;Excel 另存为 CSV 即可,GBK、UTF-8 都行。密码留空时自动生成 6 位数字。
</li>
<li>导入后点<b>导出名单(含密码)</b>,打印或发给学生。学生忘了密码,在名单里点<b>重置密码</b>。</li>
</ul>
+2 -2
View File
@@ -32,8 +32,8 @@ async function submit() {
<div class="gate">
<form class="card" @submit.prevent="submit">
<h1>物联网编程仿真平台</h1>
<p>用老师发给你的账号和密码登录</p>
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
<p>用老师发给你的用户名和密码登录</p>
<NInput v-model:value="username" placeholder="用户名" autofocus :input-props="{ autocomplete: 'username' }" />
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" />
<div v-if="error" class="error">{{ error }}</div>
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
+1 -3
View File
@@ -262,8 +262,7 @@ function exportCsv() {
if (!a) return
downloadCsv(`${a.title}-成绩.csv`, [
[
'学号',
'姓名',
'用户名',
`自动评分(${autoTotal.value})`,
...a.scoring.manual.map((m) => `${m.title}(${m.points})`),
`总分(${autoTotal.value + manualTotal.value})`,
@@ -272,7 +271,6 @@ function exportCsv() {
],
...rows.value.map((r) => [
`\t${r.username}`,
r.name,
r.status ? (r.autoScore ?? '') : '未提交',
...a.scoring.manual.map((_, i) => r.review?.scores[i] ?? ''),
r.status ? (r.autoScore ?? 0) + (r.manualScore ?? 0) : '',
+7 -10
View File
@@ -122,8 +122,7 @@ function deleteStudent(student: Student) {
}
const columns: DataTableColumns<Student> = [
{ title: '账号', key: 'username', sorter: 'default' },
{ title: '姓名', key: 'name' },
{ title: '用户名', key: 'username', sorter: 'default' },
{ title: '密码', key: 'password', render: (s) => h('code', s.password) },
{
title: '操作',
@@ -178,13 +177,13 @@ async function confirmImport() {
function exportRoster() {
if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [['学号', '姓名', '密码'], ...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`])])
downloadCsv(`${selected.value.name}-名单.csv`, [['用户名', '密码'], ...students.value.map((s) => [`\t${s.username}`, `\t${s.password}`])])
}
function downloadTemplate() {
downloadCsv('学生名单模板.csv', [
['学号', '姓名', '密码'],
['\t2024001', '张三', ''],
['用户名', '密码'],
['张三', ''],
])
}
@@ -255,8 +254,8 @@ async function signOut() {
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div>
<p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6
位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
名单是 Excel 另存的 CSV,两列:"用户名、密码"。用户名就是学生姓名,前面带的班级代号(如 ks241张三 的 ks241)会自动去掉;
全校重名时在后面加数字区分(张三2)。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。再次导入同一个班时,密码留空则不变。
</p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
</NTabPane>
@@ -289,8 +288,7 @@ async function signOut() {
<table>
<thead>
<tr>
<th>学号</th>
<th>姓名</th>
<th>用户名</th>
<th>密码</th>
<th />
</tr>
@@ -298,7 +296,6 @@ async function signOut() {
<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>
+2 -4
View File
@@ -51,8 +51,7 @@ function cell(row: Row, assignmentId: number) {
}
const columns = computed<DataTableColumns<Row>>(() => [
{ title: '学号', key: 'username', fixed: 'left', width: 110, sorter: 'default' },
{ title: '姓名', key: 'name', fixed: 'left', width: 80 },
{ title: '用户名', key: 'username', fixed: 'left', width: 130, sorter: 'default' },
...book.value.assignments.map((a) => ({
title: `${a.title}(${a.total})`,
key: `a${a.id}`,
@@ -64,10 +63,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
function exportCsv() {
downloadCsv(`${props.className}-成绩表.csv`, [
['学号', '姓名', ...book.value.assignments.map((a) => `${a.title}(${a.total})`), `合计(${fullMarks.value})`],
['用户名', ...book.value.assignments.map((a) => `${a.title}(${a.total})`), `合计(${fullMarks.value})`],
...book.value.students.map((s) => [
`\t${s.username}`,
s.name,
...book.value.assignments.map((a) => (s.scores[a.id]?.submitted ? s.scores[a.id].total : '未交')),
sum(s),
]),
+1 -1
View File
@@ -1,5 +1,5 @@
// 下载 CSV:开头带 BOM,Excel 打开时中文不乱码。
// 学号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。
// 账号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。
export function downloadCsv(filename: string, rows: (string | number | null)[][]) {
const quote = (value: string | number | null) => {
const cell = value === null ? '' : String(value)
+27 -4
View File
@@ -1,6 +1,6 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { decodeCsv, parseCsv, toRoster } from './roster'
import { decodeCsv, parseCsv, stripClassCode, toRoster } from './roster'
describe('decodeCsv', () => {
test('UTF-8(带 BOM,Excel 的"CSV UTF-8")', () => {
@@ -32,12 +32,35 @@ describe('toRoster', () => {
])
})
test('没有表头时按"账号、姓名、密码"的顺序', () => {
expect(toRoster(parseCsv('2024001,张三\n'))).toEqual([{ username: '2024001', name: '张三', password: '' }])
test('表头只有用户名和密码时,姓名用用户名;去掉姓名前的班级代号', () => {
expect(toRoster(parseCsv('用户名,密码\nks241张三,525517\n'))).toEqual([{ username: '张三', name: '张三', password: '525517' }])
})
test('没有表头时按"用户名、密码"的顺序', () => {
expect(toRoster(parseCsv('ks241张三,525517\nks241李四\n'))).toEqual([
{ username: '张三', name: '张三', password: '525517' },
{ username: '李四', name: '李四', password: '' },
])
})
test('只去掉"字母 + 数字 + 汉字"开头的代号', () => {
expect(['ks241张三', 'KS2401王超', '2024001', 'zhang3', 'abc张三', '张三'].map(stripClassCode)).toEqual([
'张三',
'王超',
'2024001',
'zhang3',
'abc张三',
'张三',
])
})
test('去掉代号后重名的标出问题', () => {
const rows = toRoster(parseCsv('用户名,密码\nks241王超,1\nks242王超,2\n'))
expect(rows.map((r) => r.problem)).toEqual([undefined, expect.stringContaining('同名')])
})
test('科学计数法的学号、空账号、空姓名标出问题', () => {
const rows = toRoster(parseCsv('学号,姓名\n2.02401E+11,张三\n,李四\n2024003,\n'))
expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '账号为空', '姓名为空'])
expect(rows.map((r) => r.problem)).toEqual([expect.stringContaining('科学计数法'), '用户名为空', '姓名为空'])
})
})
+18 -6
View File
@@ -1,6 +1,8 @@
// 读取老师上传的学生名单(CSV)。名单通常由 Excel 导出:
// 中文 Windows 上"CSV(逗号分隔)"是 GBK 编码,"CSV UTF-8"带 BOM,两种都要支持;
// 第一行可能是表头(学号、姓名、密码),没有表头时按"账号、姓名、密码"的顺序读。
// 名单只有"用户名、密码"两列,平台里的姓名就用用户名;第一行可能是表头,没有表头时按"用户名、密码"的顺序读。
// 以前的名单有"姓名"列的,照样读进来。学校别的平台的用户名在姓名前带着班级代号(ks241张三),
// 这里有班级,代号去掉,学生只用姓名登录。
export interface RosterRow {
username: string
@@ -65,27 +67,37 @@ const HEADERS = {
password: ['密码', 'password'],
}
/** 去掉姓名前面的班级代号(字母加数字):ks241张三 → 张三;不是"代号 + 汉字"的用户名不变 */
export function stripClassCode(username: string) {
return username.replace(/^[A-Za-z]+\d+(?=\p{Script=Han})/u, '')
}
/** 表格转成名单:识别表头,检查每行的问题 */
export function toRoster(table: string[][]): RosterRow[] {
if (table.length === 0) return []
const header = table[0].map((cell) => cell.trim().toLowerCase())
const column = (aliases: string[]) => header.findIndex((cell) => aliases.includes(cell))
const hasHeader = column(HEADERS.username) >= 0 && column(HEADERS.name) >= 0
const hasHeader = column(HEADERS.username) >= 0
const index = hasHeader
? { username: column(HEADERS.username), name: column(HEADERS.name), password: column(HEADERS.password) }
: { username: 0, name: 1, password: 2 }
: { username: 0, name: -1, password: 1 }
const seen = new Set<string>()
return table.slice(hasHeader ? 1 : 0).map((cells) => {
const cell = (i: number) => (i >= 0 ? (cells[i] ?? '').trim() : '')
const row: RosterRow = { username: cell(index.username), name: cell(index.name), password: cell(index.password) }
const username = stripClassCode(cell(index.username))
const row: RosterRow = { username, name: index.name >= 0 ? cell(index.name) : username, password: cell(index.password) }
// Excel 会把长数字(如 12 位学号)显示成科学计数法,导出 CSV 时也照样写出去
if (/^\d+(\.\d+)?e\+\d+$/i.test(row.username)) {
row.problem = '账号变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出'
row.problem = '用户名变成了科学计数法:在 Excel 里把这一列设为"文本"格式、重新输入后再导出'
} else if (!row.username) {
row.problem = '账号为空'
row.problem = '用户名为空'
} else if (!row.name) {
row.problem = '姓名为空'
} else if (seen.has(row.username)) {
row.problem = '名单里有同名的,在用户名后面加数字区分,如"张三2"'
}
seen.add(row.username)
return row
})
}