Files
ClassHelper/packages/desktop-client/src/renderer/views/LoginView.vue
T
LoveChengke 572295c297 feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0
- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
  共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
  (备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
  .env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
  (prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
  localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
  (/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
  主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
  服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
  尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
  数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导

验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。

注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
2026-10-02 11:18:47 +08:00

240 lines
7.2 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 { useOnboardingStore } from '../stores/onboarding.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
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);
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
await appStore.loadNotificationChannel(auth.classId);
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="请输入班级码" 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>
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
<div class="login-guide">
<el-button link type="primary" @click="onboarding.open()">
<el-icon><Guide /></el-icon>
查看使用引导
</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: var(--ch-layer-solid);
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: var(--ch-text-secondary);
}
.login-actions {
display: flex;
gap: 10px;
}
.login-actions .el-button {
flex: 1;
}
.login-guide {
display: flex;
justify-content: center;
margin-top: 10px;
}
.offline-button {
width: 100%;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
line-height: 1.7;
color: var(--ch-text-tertiary);
}
</style>