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 条),属时间相关用例,
与本次改动无关。
This commit is contained in:
LoveChengke committed 2026-10-02 11:18:47 +08:00
1 parent 391e2c1591
commit 572295c297
66 files changed
+5131 -1027

No files matched your search

@@ -36,6 +36,12 @@ interface PersistedConfig {
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
onboardingDone?: boolean;
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
theme?: 'light' | 'dark';
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
sidebarCollapsed?: boolean;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -47,8 +53,16 @@ const DEFAULT_CONFIG: PersistedConfig = {
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false,
theme: 'light',
sidebarCollapsed: false,
};
/** 主题白名单:非法值一律回落浅色 */
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
return input === 'dark' ? 'dark' : 'light';
}
/**
* 作业快捷短语的合法化:去空白、去重、限长限量。
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
@@ -181,6 +195,9 @@ function readPersisted(): PersistedConfig {
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
sidebarCollapsed: parsed.sidebarCollapsed === true,
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -223,6 +240,9 @@ export function getConfig(): DesktopStoredConfig {
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
};
}
@@ -253,6 +273,17 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) {
persisted.onboardingDone = true;
}
if (patch.theme !== undefined) {
persisted.theme = normalizeTheme(patch.theme);
}
if (patch.sidebarCollapsed !== undefined) {
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
+10 -5
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { getConfig } from './config.js';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
@@ -49,16 +50,20 @@ function isAllowedNavigation(url: string): boolean {
}
function createWindow(): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme;
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 980,
minHeight: 640,
// 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
width: 1242,
height: 582,
minWidth: 1000,
minHeight: 540,
center: true,
title: '班级小助手',
// 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest,
autoHideMenuBar: true,
backgroundColor: '#f5f7fa',
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: {
preload: path.join(currentDir, '../preload/index.js'),
contextIsolation: true,
+1
View File
@@ -30,6 +30,7 @@ export function registerIpcHandlers(): void {
node: process.versions.node ?? '',
platform: `${process.platform} ${process.arch}`,
userDataPath: app.getPath('userData'),
configPath: getConfigPath(),
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
}));
+54 -13
View File
@@ -3712,7 +3712,7 @@ async function runSettingsPagePasswordEntryCheck(
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule';
await wait(250);
location.hash = '#/settings';
location.hash = '#/settings/account';
let card = null;
for (let i = 0; i < 40 && !card; i += 1) {
await wait(100);
@@ -3756,7 +3756,7 @@ async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok:
window.desktop.islandSetAppearance({ ...before, height: 44 });
await wait(300);
location.hash = '#/settings';
location.hash = '#/settings/island';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
@@ -3838,7 +3838,7 @@ async function runSettingsPageMarginCheck(
// 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源)
location.hash = '#/schedule';
await wait(250);
location.hash = '#/settings';
location.hash = '#/settings/island';
let item = null;
for (let i = 0; i < 40 && !item; i += 1) {
await wait(100);
@@ -3880,15 +3880,15 @@ async function runSettingsPageMarginCheck(
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule';
await wait(200);
location.hash = '#/settings';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
);
location.hash = '#/schedule';
await wait(200);
location.hash = '#/settings/island';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
);
runway = item?.querySelector('.el-slider__runway') ?? null;
}
if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' };
@@ -3986,7 +3986,7 @@ async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: bo
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/settings';
location.hash = '#/settings/reminder';
// 找到「通知显示位置」那张卡片里的单选组
let group = null;
@@ -4151,6 +4151,28 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
`text=${dom?.text ?? ''}`,
);
// 初始窗口尺寸:与 ClassIsland 主窗口对齐(实测其可视区 1242x582,见 main/index.ts)
const [initialWidth, initialHeight] = win.getSize();
record(
'初始窗口尺寸与 ClassIsland 对齐(1242x582)',
initialWidth === 1242 && initialHeight === 582,
`实际 ${initialWidth}x${initialHeight}`,
);
// 初次启动引导(新装客户端首启自动弹出):真实点击走完一遍,断言完成后写入配置。
// 必须放在"登录页不含示例内容"的全文扫描**之前**——那时引导还开着,先把它的文案从屏幕上撤走。
const onboarding = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.onboardingSelfTest) return { ok: false, detail: '缺少引导自检钩子' };
return await window.__classhelperSmoke__.onboardingSelfTest();
})()`,
);
record(
'初次启动引导(首启自动弹出、可走完、状态写入配置)',
Boolean(onboarding?.ok),
String(onboarding?.detail ?? ''),
);
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号。
// 判据不写死具体示例码:用"演示/示例"字样 + 旧版占位符的**形状**(例如 G101)+ 环境变量传入的凭据,
// 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。
@@ -4308,6 +4330,25 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
);
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
// 主界面外观件(新功能回归):真实点击主题日月按钮与侧边栏汉堡按钮,
// 断言 html.dark 翻转、collapse 态生效、两者都写入主进程配置;结束时还原主题
const chrome = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.layoutChromeSelfTest) return { ok: false, detail: '缺少外观件自检钩子' };
return await window.__classhelperSmoke__.layoutChromeSelfTest();
})()`,
);
record('主题切换与侧边栏折叠(真实点击、写回配置)', Boolean(chrome?.ok), String(chrome?.detail ?? ''));
// 关于页(新功能回归):/settings/about 渲染 + 诊断信息(getAppInfo 含 configPath)
const aboutPage = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.aboutPageSelfTest) return { ok: false, detail: '缺少关于页自检钩子' };
return await window.__classhelperSmoke__.aboutPageSelfTest();
})()`,
);
record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? ''));
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
const readBadgeGeometry = async (): Promise<{
ok: boolean;