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

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { useUiStore } from '../stores/ui.js';
const route = useRoute();
const router = useRouter();
@@ -15,18 +16,75 @@ const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const ui = useUiStore();
const menuItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
{ path: '/settings', title: '设置', icon: 'Setting' },
/**
* 主侧边栏:ClassIsland 式「分组 + 子菜单」。
* - 学习:课表 / 作业 / 通知 / 成绩
* - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
* 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
*/
const menuGroups = [
{
title: '学习',
icon: 'Reading',
items: [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
],
},
{
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
},
];
/** 未读红点挂在「通知」项的图标右上角 */
function showBadge(path: string): boolean {
return path === '/notifications' && notifications.unreadCount > 0;
}
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
return group?.title ?? '学习';
});
/**
* 分组展开控制:默认只展开**当前路由所在的分组**。
*
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
* 用户手动展开其它分组不受限制(可多个同时展开)。
*/
const menuRef = ref<MenuInstance>();
const openedGroups = ref<string[]>([activeGroupTitle.value]);
watch(
() => route.path,
async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组)
await nextTick();
menuRef.value?.open(group.title);
},
);
/**
* 菜单点击回调。
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
@@ -44,9 +102,11 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const item of menuItems) {
if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
for (const group of menuGroups) {
for (const item of group.items) {
if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
}
}
}
}
@@ -106,29 +166,50 @@ onUnmounted(() => {
<template>
<el-container class="layout">
<el-aside width="200px" class="aside ch-nav">
<el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
<div class="brand">
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
<button
type="button"
class="nav-toggle"
aria-label="折叠或展开侧边栏"
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
data-test="sidebar-toggle"
@click="ui.toggleSidebar()"
>
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
</button>
<template v-if="!ui.sidebarCollapsed">
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
<span class="brand-text">班级小助手</span>
</template>
</div>
<el-menu
ref="menuRef"
:default-active="activeMenu"
:default-openeds="openedGroups"
:collapse="ui.sidebarCollapsed"
:collapse-width="64"
class="menu"
@select="handleMenuSelect"
>
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
<span>{{ group.title }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge v-if="showBadge(item.path)" :value="notifications.unreadCount" :max="99" class="menu-badge" />
</span>
<span>{{ item.title }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="aside-footer">
<div v-if="!ui.sidebarCollapsed" class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
</div>
@@ -142,6 +223,19 @@ onUnmounted(() => {
<span class="server-url">{{ appStore.serverUrl }}</span>
</div>
<div class="header-right">
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
<el-button
class="theme-toggle"
text
circle
data-test="theme-toggle"
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="ui.toggleTheme()"
>
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click">
<span class="user-chip">
<el-icon><UserFilled /></el-icon>
@@ -150,7 +244,7 @@ onUnmounted(() => {
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="go('settings')">
<el-dropdown-item @click="go('settings-general')">
<el-icon><Setting /></el-icon>
设置
</el-dropdown-item>
@@ -198,12 +292,38 @@ onUnmounted(() => {
align-items: center;
gap: 10px;
height: 52px;
padding: 0 16px;
padding: 0 10px 0 12px;
font-weight: 600;
font-size: 14px;
color: var(--ch-text);
}
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
border: none;
border-radius: var(--ch-radius-control);
background: transparent;
color: var(--ch-text-secondary);
cursor: pointer;
transition: background 0.15s ease;
}
.nav-toggle:hover {
background: var(--ch-hover-soft);
}
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
.aside.ch-nav:has(.el-menu--collapse) .brand {
justify-content: center;
padding: 0;
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
.brand-logo {
display: inline-flex;
@@ -222,10 +342,14 @@ onUnmounted(() => {
.menu {
flex: 1;
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-right: none;
background: transparent;
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
--el-menu-hover-bg-color: var(--ch-hover-soft);
--el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text);
}
@@ -279,7 +403,7 @@ onUnmounted(() => {
.header {
height: 52px;
background: rgba(255, 255, 255, 0.6);
background: var(--ch-header-bg);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border);
display: flex;
@@ -313,7 +437,12 @@ onUnmounted(() => {
}
.user-chip:hover {
background: rgba(0, 0, 0, 0.04);
background: var(--ch-hover-soft);
}
.theme-toggle {
margin-right: 2px;
color: var(--ch-text-secondary);
}
.main {