Files
ClassHelper/packages/desktop-client/src/renderer/views/LoginView.vue
T
LoveChengke 1073436ad6 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 同步
2026-09-12 15:51:08 +08:00

227 lines
6.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const formRef = ref<FormInstance>();
const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' });
const testing = ref(false);
const testResult = ref<'ok' | 'fail' | null>(null);
const rules: FormRules = {
serverUrl: [
{
validator: (_rule, value: string, callback: (error?: Error) => void) => {
if (!value?.trim()) callback(new Error('请输入服务器地址'));
else if (!normalizeServerUrl(value)) callback(new Error('地址格式应为 http://主机:端口'));
else callback();
},
trigger: 'blur',
},
],
code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
onMounted(async () => {
// 主进程已保存过服务器地址/上次用户名则预填
if (window.desktop) {
const config = await window.desktop.getConfig();
if (config.serverUrl) form.serverUrl = config.serverUrl;
if (config.username) form.code = config.username;
}
});
async function testConnection(): Promise<void> {
const normalized = normalizeServerUrl(form.serverUrl);
if (!normalized) {
ElMessage.warning('请先填写正确的服务器地址');
return;
}
testing.value = true;
testResult.value = null;
try {
setApiBaseUrl(normalized);
const result = await pingHealth();
testResult.value = result.ok ? 'ok' : 'fail';
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
try {
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}`);
await router.replace('/schedule');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败');
}
}
/** 已保存登录态但服务器不可达时,允许用缓存数据离线进入 */
async function enterOffline(): Promise<void> {
if (!auth.isAuthenticated) return;
await appStore.init(appStore.serverUrl);
ElMessage.warning('已进入离线模式,仅显示上次同步的数据');
await router.replace('/schedule');
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-head">
<el-icon :size="30" color="#409eff"><School /></el-icon>
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form-item label="服务器地址" prop="serverUrl">
<el-input v-model="form.serverUrl" placeholder="http://127.0.0.1:4000" clearable>
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="班级码" prop="code">
<el-input
v-model="form.code"
placeholder="例如 G101(由老师在班级管理中提供)"
clearable
@keyup.enter="submit"
>
<template #prefix>
<el-icon><School /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="班级密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="班级密码"
show-password
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<div class="login-actions">
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div>
<el-alert
v-if="testResult === 'ok'"
class="mt-12"
type="success"
:closable="false"
title="服务器可达,可以登录"
/>
<el-alert
v-if="testResult === 'fail'"
class="mt-12"
type="error"
:closable="false"
title="无法连接服务器"
description="请确认后端已启动(pnpm dev:server)、地址与端口正确、防火墙未拦截。"
/>
</el-form>
<el-divider v-if="auth.isAuthenticated">或</el-divider>
<el-button v-if="auth.isAuthenticated" class="offline-button" @click="enterOffline">
<el-icon><Connection /></el-icon>
离线进入(使用本地缓存数据)
</el-button>
<p class="login-tip">
班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
<br />
登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
</p>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 55%, #409eff 100%);
}
.login-card {
width: 420px;
background: #fff;
border-radius: 14px;
padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
}
.login-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.login-title {
margin: 0;
font-size: 20px;
}
.login-sub {
margin: 4px 0 0;
font-size: 12px;
color: #909399;
}
.login-actions {
display: flex;
gap: 10px;
}
.login-actions .el-button {
flex: 1;
}
.offline-button {
width: 100%;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
line-height: 1.7;
color: #c0c4cc;
}
</style>