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;