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,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { STORAGE_KEYS } from '@classhelper/shared';
|
||||
import { authApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
@@ -22,6 +23,7 @@ const { isMobile } = useResponsive();
|
||||
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
|
||||
* - 作业 / 通知:所有教师
|
||||
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
|
||||
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
|
||||
*/
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
@@ -33,6 +35,7 @@ const menuItems = [
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
|
||||
];
|
||||
|
||||
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
|
||||
@@ -141,12 +144,90 @@ async function submitPassword(): Promise<void> {
|
||||
await auth.logout();
|
||||
void router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 首次登录引导 */
|
||||
|
||||
/**
|
||||
* 引导版本:内容改版后 +1,看过旧版的老用户会再收到一次新版引导。
|
||||
* 「已看过」存 localStorage(STORAGE_KEYS.onboarding,值为版本号)。
|
||||
*/
|
||||
const ONBOARDING_VERSION = 1;
|
||||
|
||||
const tourOpen = ref(false);
|
||||
|
||||
/** 「看过」落盘:走完、跳过(右上角 × / Esc)都算看过,之后不再自动弹出 */
|
||||
function markOnboardingSeen(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.onboarding, String(ONBOARDING_VERSION));
|
||||
} catch {
|
||||
// localStorage 不可用(隐私模式等)时仅本次会话内生效
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEYS.onboarding) === String(ONBOARDING_VERSION)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// 等布局与首屏数据渲染稳定后再弹,避免和首屏加载抢注意力
|
||||
window.setTimeout(() => {
|
||||
tourOpen.value = true;
|
||||
}, 600);
|
||||
});
|
||||
|
||||
/**
|
||||
* 引导锚点:按 data-tour 属性惰性查询 DOM(弹出某一步时才找元素)。
|
||||
* 找不到(例如手机小屏没有侧边栏)就返回 null,el-tour 会把卡片放到屏幕居中,不会报错。
|
||||
*/
|
||||
function tourTarget(name: string): () => HTMLElement | null {
|
||||
return () => document.querySelector<HTMLElement>(`[data-tour="${name}"]`);
|
||||
}
|
||||
|
||||
const tourSteps = computed(() => [
|
||||
{
|
||||
target: null,
|
||||
title: '欢迎使用班级小助手',
|
||||
description:
|
||||
'教师与管理员在这里发布课表、作业、通知与成绩,学生通过桌面客户端实时接收。花一分钟认识一下界面。',
|
||||
},
|
||||
isMobile.value
|
||||
? {
|
||||
target: tourTarget('nav-toggle'),
|
||||
title: '打开功能菜单',
|
||||
description: '小屏幕下侧边栏收进抽屉,点左上角按钮即可唤出全部功能页面。',
|
||||
placement: 'bottom-start',
|
||||
}
|
||||
: {
|
||||
target: tourTarget('side-nav'),
|
||||
title: '左侧功能菜单',
|
||||
description:
|
||||
'菜单按角色显示:管理员可管理班级、学生与教师;班主任与科任老师可维护课表、发布作业与通知、录入成绩。',
|
||||
// 侧栏是全高元素,默认 bottom 会把卡片推到视口外,必须放右侧
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: tourTarget('conn-tag'),
|
||||
title: '实时通道',
|
||||
description: '绿色表示与后端保持实时连接,发布的内容会立刻推送到对应班级的学生端与灵动岛。',
|
||||
},
|
||||
{
|
||||
target: tourTarget('user-chip'),
|
||||
title: '账号菜单',
|
||||
description: '在这里修改密码、退出登录,也可以随时重看本引导。',
|
||||
},
|
||||
{
|
||||
target: null,
|
||||
title: '开始使用',
|
||||
description: '发布第一条内容试试吧。以后随时可以点右上角头像 →「使用引导」重看。',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<!-- 桌面/平板:常驻深色侧边栏 -->
|
||||
<el-aside v-if="!isMobile" width="210px" class="layout-aside ch-sidebar">
|
||||
<el-aside v-if="!isMobile" width="210px" class="layout-aside ch-sidebar" data-tour="side-nav">
|
||||
<div class="brand">
|
||||
<el-icon :size="22"><School /></el-icon>
|
||||
<span class="brand-text">班级小助手</span>
|
||||
@@ -169,12 +250,13 @@ async function submitPassword(): Promise<void> {
|
||||
class="nav-toggle"
|
||||
aria-label="打开菜单"
|
||||
:aria-expanded="drawerVisible ? 'true' : 'false'"
|
||||
data-tour="nav-toggle"
|
||||
@click="drawerVisible = true"
|
||||
>
|
||||
<el-icon :size="20"><Menu /></el-icon>
|
||||
</button>
|
||||
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span>
|
||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light">
|
||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
|
||||
<span class="header-conn">{{ connectionText }}</span>
|
||||
</el-tag>
|
||||
<!-- 小屏:连接正常时不显示任何圆点/标签,只在异常态显示带文字的紧凑标签 -->
|
||||
@@ -194,7 +276,7 @@ async function submitPassword(): Promise<void> {
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-chip">
|
||||
<span class="user-chip" data-tour="user-chip">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template v-if="!isMobile">
|
||||
{{ auth.displayName }}
|
||||
@@ -209,6 +291,10 @@ async function submitPassword(): Promise<void> {
|
||||
<el-icon><Lock /></el-icon>
|
||||
修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="tourOpen = true">
|
||||
<el-icon><Guide /></el-icon>
|
||||
使用引导
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
@@ -265,6 +351,21 @@ async function submitPassword(): Promise<void> {
|
||||
<el-button type="primary" @click="submitPassword">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!--
|
||||
新手引导:首次登录自动弹出(见 script 的 onMounted),头像菜单「使用引导」可重看。
|
||||
target 为 null 的步骤(首尾两步)由 el-tour 放到屏幕居中;其余步骤锚定真实 UI。
|
||||
-->
|
||||
<el-tour v-model="tourOpen" @close="markOnboardingSeen" @finish="markOnboardingSeen">
|
||||
<el-tour-step
|
||||
v-for="(step, index) in tourSteps"
|
||||
:key="index"
|
||||
:target="step.target"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
:placement="step.placement"
|
||||
/>
|
||||
</el-tour>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in new issue
Block a user