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:
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 {
|
||||
|
||||
Reference in new issue
Block a user