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
@@ -908,10 +908,13 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
|
||||
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
|
||||
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
|
||||
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
|
||||
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
|
||||
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
|
||||
const online = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.onlineScenario();
|
||||
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })});
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
|
||||
@@ -17,7 +17,11 @@ import { api } from './http.js';
|
||||
|
||||
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
|
||||
export const authApi = {
|
||||
/** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */
|
||||
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
||||
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
|
||||
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
|
||||
api.post('/auth/class-login', payload),
|
||||
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
||||
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
||||
};
|
||||
|
||||
+12
-3
@@ -1,4 +1,4 @@
|
||||
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
|
||||
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
|
||||
import { fetchWithCache } from './index.js';
|
||||
|
||||
export interface SmokeCheckResult {
|
||||
@@ -127,7 +127,10 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
||||
*/
|
||||
export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
export async function onlineScenario(credentials?: {
|
||||
code: string;
|
||||
password: string;
|
||||
}): Promise<SmokeCheckResult> {
|
||||
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
|
||||
import('../stores/auth.js'),
|
||||
import('../stores/app.js'),
|
||||
@@ -144,8 +147,14 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
const detail: string[] = [];
|
||||
|
||||
try {
|
||||
await auth.login(serverUrl, 'student01', 'student123');
|
||||
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入
|
||||
const code = credentials?.code ?? 'G101';
|
||||
const password = credentials?.password ?? '123456';
|
||||
if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101');
|
||||
|
||||
await auth.login(serverUrl, code, password);
|
||||
detail.push(`login=${auth.user?.name ?? '-'}`);
|
||||
detail.push(`classSession=${auth.isClassSession}`);
|
||||
|
||||
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
|
||||
const { router } = await import('../router/index.js');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared';
|
||||
import { STORAGE_KEYS, type LoginResponse, type SessionUser } from '@classhelper/shared';
|
||||
import { authApi } from '../api/index.js';
|
||||
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
|
||||
import { cacheGet, cacheSet } from '../cache/db.js';
|
||||
@@ -15,13 +15,17 @@ const PROFILE_KEY = 'me';
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(null);
|
||||
const user = ref<StudentDto | null>(null);
|
||||
const user = ref<SessionUser | null>(null);
|
||||
const loading = ref(false);
|
||||
const offlineSession = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
const displayName = computed(() => user.value?.name ?? '未登录');
|
||||
const classId = computed(() => user.value?.classId ?? null);
|
||||
/** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */
|
||||
const isClassSession = computed(() => user.value?.classSession === true);
|
||||
/** 班级码(登录账号,便于设置页展示) */
|
||||
const classCode = computed(() => user.value?.classCode ?? '');
|
||||
|
||||
setTokenProvider(() => token.value);
|
||||
|
||||
@@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
|
||||
}
|
||||
|
||||
/** 登录:写主进程配置 + 缓存资料 */
|
||||
async function login(serverUrl: string, username: string, password: string): Promise<void> {
|
||||
/**
|
||||
* 班级账号登录(学生端主入口):班级码 + 班级密码。
|
||||
*
|
||||
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体;
|
||||
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
|
||||
* 保存的 username 字段记录班级码,便于下次预填。
|
||||
*/
|
||||
async function login(serverUrl: string, code: string, password: string): Promise<void> {
|
||||
const normalized = normalizeServerUrl(serverUrl);
|
||||
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
|
||||
setApiBaseUrl(normalized);
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload: LoginResponse = await authApi.login({ username, password });
|
||||
const payload: LoginResponse = await authApi.classLogin({ code, password });
|
||||
applyToken(payload.token);
|
||||
user.value = payload.user as StudentDto;
|
||||
user.value = payload.user;
|
||||
offlineSession.value = false;
|
||||
await cacheSet('profile', PROFILE_KEY, user.value);
|
||||
await persistSession(normalized, username, payload.token);
|
||||
await persistSession(normalized, code, payload.token);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -80,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const cachedProfile = await cacheGet<StudentDto>('profile', PROFILE_KEY).catch(() => null);
|
||||
const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null);
|
||||
if (cachedProfile) user.value = cachedProfile.value;
|
||||
|
||||
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
|
||||
@@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isAuthenticated,
|
||||
displayName,
|
||||
classId,
|
||||
isClassSession,
|
||||
classCode,
|
||||
login,
|
||||
restore,
|
||||
refreshProfile,
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -130,9 +133,10 @@ onUnmounted(() => {
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">我的成绩</h2>
|
||||
<h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
|
||||
<p class="page-subtitle">
|
||||
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
|
||||
<span v-if="auth.isClassSession">· 班级账号:显示全班成绩总览</span>
|
||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,15 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
||||
completed: next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
|
||||
ElMessage.success(
|
||||
auth.isClassSession
|
||||
? next
|
||||
? '已按全班标记为完成'
|
||||
: '已取消全班的完成标记'
|
||||
: next
|
||||
? '已标记为完成'
|
||||
: '已取消完成标记',
|
||||
);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
||||
} finally {
|
||||
@@ -170,7 +178,15 @@ onUnmounted(() => {
|
||||
:loading="submitting"
|
||||
@click.stop="toggleComplete(row)"
|
||||
>
|
||||
{{ row.completed ? '取消完成' : '标记完成' }}
|
||||
{{
|
||||
row.completed
|
||||
? auth.isClassSession
|
||||
? '取消全班完成'
|
||||
: '取消完成'
|
||||
: auth.isClassSession
|
||||
? '全班标记完成'
|
||||
: '标记完成'
|
||||
}}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -213,7 +229,9 @@ onUnmounted(() => {
|
||||
:disabled="appStore.offline"
|
||||
@click="toggleComplete(current)"
|
||||
>
|
||||
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
|
||||
{{
|
||||
current.completed ? '取消完成标记' : auth.isClassSession ? '全班标记为已完成' : '标记为已完成'
|
||||
}}
|
||||
</el-button>
|
||||
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' });
|
||||
const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' });
|
||||
const testing = ref(false);
|
||||
const testResult = ref<'ok' | 'fail' | null>(null);
|
||||
|
||||
@@ -29,7 +29,7 @@ const rules: FormRules = {
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ onMounted(async () => {
|
||||
if (window.desktop) {
|
||||
const config = await window.desktop.getConfig();
|
||||
if (config.serverUrl) form.serverUrl = config.serverUrl;
|
||||
if (config.username) form.username = config.username;
|
||||
if (config.username) form.code = config.username;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,10 +66,10 @@ async function submit(): Promise<void> {
|
||||
if (!valid) return;
|
||||
|
||||
try {
|
||||
await auth.login(form.serverUrl, form.username.trim(), form.password);
|
||||
await auth.login(form.serverUrl, form.code.trim(), form.password);
|
||||
await appStore.init(form.serverUrl);
|
||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||
ElMessage.success(`欢迎回来,${auth.displayName}`);
|
||||
ElMessage.success(`已进入 ${auth.displayName}`);
|
||||
await router.replace('/schedule');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '登录失败');
|
||||
@@ -92,7 +92,7 @@ async function enterOffline(): Promise<void> {
|
||||
<el-icon :size="30" color="#409eff"><School /></el-icon>
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
|
||||
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,19 +105,24 @@ async function enterOffline(): Promise<void> {
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
|
||||
<el-form-item label="班级码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="例如 G101(由老师在班级管理中提供)"
|
||||
clearable
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
<el-icon><School /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-form-item label="班级密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder="班级密码"
|
||||
show-password
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
@@ -156,9 +161,9 @@ async function enterOffline(): Promise<void> {
|
||||
</el-button>
|
||||
|
||||
<p class="login-tip">
|
||||
演示账号:student01 / student123(高一(1)班)
|
||||
班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
|
||||
<br />
|
||||
数据来源:教师或管理员在 Web 管理端发布的内容。
|
||||
登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -86,7 +86,10 @@ declare global {
|
||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||
onlineScenario(credentials?: {
|
||||
code: string;
|
||||
password: string;
|
||||
}): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
|
||||
islandRealtimeScenario(): Promise<{
|
||||
ok: boolean;
|
||||
|
||||
Reference in new issue
Block a user