feat(class-account): 学生端主体改为班级(班级码 + 班级密码)+ 数据按班级隔离
服务端:
- Class 新增 code(唯一)/passwordHash 与迁移(老数据回填 C00001 形式, 未设密码禁止登录)
- POST /auth/class-login: 班级码 + 班级密码 -> classSession 会话(sub=classId)
- PATCH /classes/:id/class-account: 管理员改班级码/重置密码(唯一性、格式校验)
- auth 中间件/socket 握手支持班级会话(sub 为 classId, 回查 Class 表, 加入 user:{classId} 房间)
- lib/session.ts: 班级设备代全班操作(通知已读/作业完成/成绩总览按全班读写), 普通学生账号行为不变
- 叫人消息同时广播到 user:{classId}, 班级设备立即上岛
- 班级列表/详情按权限返回班级码与密码状态(哈希不外泄)
客户端:
- 登录页改为班级码 + 班级密码(个人学生入口移除, /auth/login 接口保留兼容)
- 作业按钮/成绩标题按班级会话显示(全班标记完成 / 本班成绩)
- 冒烟脚本自动准备班级账号凭据(兼容全新与升级安装)
Web 管理端:
- 班级管理新增「班级账号」列与设置弹窗(改班级码/重置密码/随机生成)
- 新建班级可选自定义班级码, 默认由服务端生成
验证: verify:e2e 125/125(新增班级账号 18 项) verify:web 18/18 verify:desktop 40/40
(login=高一(1)班 classSession=true)
文档: README 新增「学生端班级账号」章节、API/迁移/验收/默认账号更新; 安装包 README 同步
This commit is contained in:
1 parent
8b85079d81
commit
1073436ad6
38 files changed
+1289
-157
No files matched your search
@@ -26,6 +26,7 @@ import {
|
||||
type TimeLayoutDto,
|
||||
type TimeLayoutImportResult,
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateClassAccountRequest,
|
||||
type UpdateClassRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -63,6 +64,9 @@ export const classApi = {
|
||||
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
|
||||
removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
|
||||
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
updateAccount: (id: string, payload: UpdateClassAccountRequest): Promise<ClassDto> =>
|
||||
api.patch(`${API_PATHS.classes}/${id}/class-account`, payload),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 课程 */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ClassDto,
|
||||
type CourseDto,
|
||||
type StudentDto,
|
||||
type UpdateClassAccountRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, teacherApi } from '@/api';
|
||||
@@ -43,7 +44,7 @@ async function loadClasses(): Promise<void> {
|
||||
const formVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', grade: '' });
|
||||
const form = reactive({ name: '', grade: '', code: '' });
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
|
||||
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
|
||||
@@ -53,6 +54,7 @@ function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.grade = '';
|
||||
form.code = '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ function openEdit(row: ClassDto): void {
|
||||
editingId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.grade = row.grade;
|
||||
form.code = '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -71,13 +74,69 @@ async function submitForm(): Promise<void> {
|
||||
await classApi.update(editingId.value, { name: form.name, grade: form.grade });
|
||||
ElMessage.success('班级已更新');
|
||||
} else {
|
||||
await classApi.create({ name: form.name, grade: form.grade });
|
||||
await classApi.create({
|
||||
name: form.name,
|
||||
grade: form.grade,
|
||||
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
||||
});
|
||||
ElMessage.success('班级创建成功');
|
||||
}
|
||||
formVisible.value = false;
|
||||
await loadClasses();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 班级账号(班级码 + 班级密码) */
|
||||
|
||||
const accountVisible = ref(false);
|
||||
const accountSaving = ref(false);
|
||||
const accountTarget = ref<ClassDto | null>(null);
|
||||
const accountForm = reactive({ code: '', password: '' });
|
||||
|
||||
/** 打开班级账号设置:班级码即学生端「班级登录」的账号 */
|
||||
function openAccount(row: ClassDto): void {
|
||||
accountTarget.value = row;
|
||||
accountForm.code = row.code ?? '';
|
||||
accountForm.password = '';
|
||||
accountVisible.value = true;
|
||||
}
|
||||
|
||||
/** 一键生成一个易读的班级码(避开 0/O/1/I 等易混字符) */
|
||||
function suggestCode(): void {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let body = '';
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
body += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
}
|
||||
accountForm.code = body;
|
||||
}
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
const target = accountTarget.value;
|
||||
if (!target) return;
|
||||
if (!accountForm.code.trim() && !accountForm.password) {
|
||||
ElMessage.warning('请填写新的班级码或班级密码');
|
||||
return;
|
||||
}
|
||||
|
||||
accountSaving.value = true;
|
||||
try {
|
||||
const payload: UpdateClassAccountRequest = {};
|
||||
if (accountForm.code.trim() && accountForm.code.trim() !== target.code) {
|
||||
payload.code = accountForm.code.trim().toUpperCase();
|
||||
}
|
||||
if (accountForm.password) payload.password = accountForm.password;
|
||||
|
||||
const updated = await classApi.updateAccount(target.id, payload);
|
||||
ElMessage.success(
|
||||
`班级账号已更新:班级码 ${updated.code ?? '-'}${accountForm.password ? ',班级密码已重置' : ''}`,
|
||||
);
|
||||
accountVisible.value = false;
|
||||
await loadClasses();
|
||||
} finally {
|
||||
accountSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClass(row: ClassDto): Promise<void> {
|
||||
await ElMessageBox.confirm(
|
||||
`删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`,
|
||||
@@ -267,12 +326,24 @@ onUnmounted(() => {
|
||||
<el-table-column label="通知" width="80">
|
||||
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班级账号(学生端登录)" width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.code">
|
||||
<el-tag type="info" effect="plain">{{ row.code }}</el-tag>
|
||||
<el-tag :type="row.hasPassword ? 'success' : 'warning'" size="small" class="account-tag">
|
||||
{{ row.hasPassword ? '已设密码' : '未设密码' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<el-table-column label="操作" width="310" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="isAdmin" link type="warning" @click="openAccount(row)">班级账号</el-button>
|
||||
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -280,6 +351,38 @@ onUnmounted(() => {
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 班级账号:班级码 + 班级密码(学生端「班级登录」凭据) -->
|
||||
<el-dialog v-model="accountVisible" title="班级账号(学生端登录)" width="460px">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||||
<template #title>
|
||||
学生端以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
|
||||
作业完成、通知已读都会按全班记录。
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="班级">
|
||||
<el-input :model-value="accountTarget?.name ?? ''" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="班级码">
|
||||
<el-input v-model="accountForm.code" placeholder="4~16 位字母或数字" style="width: 220px" />
|
||||
<el-button link type="primary" class="ml-8" @click="suggestCode">随机生成</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input
|
||||
v-model="accountForm.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="留空表示不修改(至少 6 位)"
|
||||
style="width: 220px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="accountVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="accountSaving" @click="submitAccount">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建 / 编辑 -->
|
||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
@@ -289,6 +392,9 @@ onUnmounted(() => {
|
||||
<el-form-item label="年级" prop="grade">
|
||||
<el-input v-model="form.grade" placeholder="例如 高一" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editingId" label="班级码">
|
||||
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
@@ -393,3 +499,15 @@ onUnmounted(() => {
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user