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 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 0.1.0.0
version: 1.0.0.0
apiVersion: 2.0.0.0
author: 班级小助手
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "0.1.0.0";
private const string PluginVersion = "1.0.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
@@ -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;
@@ -7,13 +7,18 @@ import {
subscribeIslandMarkAllRead,
subscribeIslandMarkRead,
} from './island/bridge.js';
import OnboardingWelcome from './components/OnboardingWelcome.vue';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useOnboardingStore } from './stores/onboarding.js';
import { useRealtimeStore } from './stores/realtime.js';
import { useUiStore } from './stores/ui.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const ui = useUiStore();
const realtime = useRealtimeStore();
const booting = ref(true);
@@ -27,6 +32,10 @@ const bootText = ref('正在读取本地配置...');
*/
onMounted(async () => {
try {
bootText.value = '正在读取本地配置...';
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
await ui.init();
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
@@ -52,6 +61,17 @@ onMounted(async () => {
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
window.setTimeout(() => {
void window.desktop
?.getConfig()
.then((config) => {
if (config.onboardingDone !== true) onboarding.open();
})
.catch(() => undefined);
}, 400);
} finally {
booting.value = false;
}
@@ -69,6 +89,8 @@ onUnmounted(() => {
<p class="boot-text">{{ bootText }}</p>
</div>
<router-view v-else />
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
<OnboardingWelcome v-if="!booting" />
</template>
<style scoped>
+254 -9
View File
@@ -93,19 +93,26 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs
return predicate();
}
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配;侧边栏是「分组 + 子菜单」结构) */
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
{ label: '课表', path: '/schedule' },
{ label: '作业', path: '/homeworks' },
{ label: '通知', path: '/notifications' },
{ label: '成绩', path: '/grades' },
{ label: '设置', path: '/settings' },
/** 设置组的代表子项:设置入口改成分组后,用「通用」验证设置子页导航可用 */
{ label: '通用', path: '/settings/general' },
];
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项 */
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
{ title: '学习', labels: ['课表', '作业', '通知', '成绩'] },
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
];
/**
* 冒烟自检 3:侧边栏点击导航。
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
* 这是"点击左侧边栏没反应"这类问题的回归测试。
* 冒烟自检 3:侧边栏点击导航(分组结构版)。
* 先点分组标题展开,再逐个点击子项,验证每次点击后路由与视图都发生切换 ——
* 这是"点击左侧边栏没反应"这类问题的回归测试(侧边栏改为「学习/设置」分组后重写)。
*/
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js');
@@ -113,20 +120,43 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
const findMenuItem = (label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
Array.from(element.querySelectorAll('span')).some(
(span) => (span.textContent ?? '').trim() === label,
),
);
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
const findGroupTitle = (title: string): HTMLElement | undefined =>
findByLabel('.el-sub-menu__title', title);
if (!findMenuItem('课表')) {
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
if (!findGroupTitle('学习') || !findGroupTitle('设置')) {
return { ok: false, detail: '侧边栏分组未渲染(当前不在主布局或未登录)' };
}
const visited: string[] = [];
const failures: string[] = [];
// 展开分组:默认只展开"当前路由所在分组",其它组需要先点开。
// 判据用"子项是否可见"而不是"盲点一次标题" —— 标题点击是 toggle,
// 对已经展开的组再点一下会把它**收起**,后续点不到子项。
const isVisible = (element: HTMLElement | undefined): boolean =>
Boolean(element && element.offsetParent !== null);
for (const group of MENU_GROUPS) {
const firstItem = findMenuItem(group.labels[0] ?? '');
if (!isVisible(firstItem)) {
findGroupTitle(group.title)?.click();
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
// 分组结构自检:两个分组各自应包含全部子项
for (const group of MENU_GROUPS) {
for (const label of group.labels) {
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
}
}
for (const { label, path } of EXPECTED_MENU) {
const element = findMenuItem(label);
if (!element) {
@@ -818,6 +848,66 @@ export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult
}
}
/**
* 冒烟自检(不需要后端):**初次启动引导**。
*
* 冒烟每次都用全新配置目录(等于"首次启动"),因此引导必须自动弹出;
* 这里真实点击把它一步步走完,断言:遮罩关闭 + `onboardingDone` 已写入配置。
* 关闭判据用「元素从 DOM 消失」——引导是 v-if 自绘遮罩(特意不用 el-dialog,
* 避免"关闭后 DOM 残留在文档里"的误报,见 AGENTS §7 第 22 条)。
*/
export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
try {
// 引导在启动后延迟 ~400ms 弹出,这里轮询等它出现
const appearDeadline = Date.now() + 5000;
while (Date.now() < appearDeadline && !document.querySelector('.onboarding-welcome')) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!document.querySelector('.onboarding-welcome')) {
const config = await window.desktop?.getConfig?.();
return {
ok: false,
detail: `初次启动引导未自动出现(onboardingDone=${config?.onboardingDone ?? '-'})`,
};
}
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
const clicks: string[] = [];
for (let attempt = 0; attempt < 12; attempt += 1) {
const action = document.querySelector('.onboarding-welcome [data-action="finish"]')
? 'finish'
: 'next';
const button = document.querySelector<HTMLElement>(
`.onboarding-welcome [data-action="${action}"]`,
);
if (!button) break;
clicks.push(action);
button.click();
if (action === 'finish') break;
await new Promise((resolve) => setTimeout(resolve, 150));
}
let closed = !document.querySelector('.onboarding-welcome');
const closeDeadline = Date.now() + 2000;
while (Date.now() < closeDeadline && !closed) {
await new Promise((resolve) => setTimeout(resolve, 100));
closed = !document.querySelector('.onboarding-welcome');
}
const config = await window.desktop?.getConfig?.();
const done = config?.onboardingDone === true;
return {
ok: closed && done,
detail: `步骤点击=[${clicks.join(',')}] 已关闭=${closed} onboardingDone=${config?.onboardingDone ?? '-'}`,
};
} catch (error) {
return {
ok: false,
detail: `引导自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -834,6 +924,158 @@ export async function sessionCleanup(): Promise<SmokeCheckResult> {
}
}
/**
* 冒烟自检(不需要后端):主界面外观件 —— 主题切换与侧边栏折叠。
* 走**真实 DOM 点击**(顶栏日月按钮 / 汉堡按钮),验证:
* 1) html.dark 类与 store 同步翻转,且写入主进程配置;
* 2) 侧边栏折叠后 el-menu 进入 collapse 态、宽度收窄,配置同步;
* 结束时把主题还原为初始值(后续截图/几何用例依赖固定主题)。
*/
export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
try {
const { router } = await import('../router/index.js');
await router.push('/schedule');
if (!(await waitUntil(() => Boolean(document.querySelector('[data-test="theme-toggle"]')), 6000))) {
return { ok: false, detail: '主布局未渲染(找不到主题切换按钮)' };
}
const clickToggle = (selector: string): boolean => {
const node = document.querySelector<HTMLElement>(selector);
if (!node) return false;
node.click();
return true;
};
// 1) 主题:真实点击日月按钮 → html.dark 翻转 → 配置落盘
const initialDark = document.documentElement.classList.contains('dark');
if (!clickToggle('[data-test="theme-toggle"]')) return { ok: false, detail: '主题按钮点击失败' };
const flipped = await waitUntil(
() => document.documentElement.classList.contains('dark') !== initialDark,
2500,
);
const flippedDark = document.documentElement.classList.contains('dark');
const configAfterFlip = await window.desktop?.getConfig?.();
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
// 2) 侧边栏:点汉堡折叠 → collapse 态 + 宽度收窄 → 配置落盘 → 再展开还原
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthBefore = asideBefore?.offsetWidth ?? 0;
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
const collapsed = await waitUntil(() => Boolean(document.querySelector('.el-menu--collapse')), 2500);
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthAfter = asideAfter?.offsetWidth ?? 0;
const configAfterCollapse = await window.desktop?.getConfig?.();
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
// a) 菜单里不允许残留任何**可见**的文字(Element Plus 靠宽度归零隐藏,一旦被外层样式
// 干扰就会留"半截字");
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px),否则看起来"没对齐"。
const centerXOf = (element: Element | null | undefined): number | null => {
if (!element) return null;
const rect = element.getBoundingClientRect();
return rect.width > 0 ? rect.left + rect.width / 2 : null;
};
const leftoverText: string[] = [];
for (const node of Array.from(
document.querySelectorAll('.aside .el-menu-item > span, .aside .el-sub-menu__title > span'),
)) {
const element = node as HTMLElement;
if (element.classList.contains('menu-icon-slot')) continue;
const text = (element.textContent ?? '').trim();
if (text && element.offsetParent !== null && element.getClientRects().length > 0) {
const rect = element.getBoundingClientRect();
// 宽高都归零但 display 不为 none 的,正是"0 宽溢出"的残影来源
if (rect.width < 1) leftoverText.push(`0宽:${text.slice(0, 6)}`);
else leftoverText.push(`可见:${text.slice(0, 6)}`);
}
}
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
const iconCenters = Array.from(
document.querySelectorAll('.aside .el-menu--collapse .el-sub-menu__title .el-icon'),
).map((node) => centerXOf(node));
const iconDeltas = iconCenters
.filter((value): value is number => value !== null && toggleCenter !== null)
.map((value) => Math.abs(value - (toggleCenter as number)));
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
clickToggle('[data-test="sidebar-toggle"]');
await waitUntil(() => !document.querySelector('.el-menu--collapse'), 2500);
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
// 在加这条结构性保证之前,两个分组全展开会把 footer 挤出屏幕(用户反馈)。
const footer = document.querySelector<HTMLElement>('.aside-footer');
const footerRect = footer?.getBoundingClientRect();
const footerVisible =
Boolean(footer && footer.offsetParent !== null) &&
Boolean(footerRect && footerRect.bottom <= window.innerHeight + 1);
// 3) 还原主题,避免影响后续用例
clickToggle('[data-test="theme-toggle"]');
await waitUntil(() => document.documentElement.classList.contains('dark') === initialDark, 2500);
const ok =
flipped &&
themePersisted &&
collapsed &&
collapsePersisted &&
widthAfter < widthBefore &&
widthAfter <= 80 &&
leftoverText.length === 0 &&
iconsAligned &&
footerVisible;
return {
ok,
detail:
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 落盘=${collapsePersisted} ` +
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
`侧栏底部可见=${footerVisible}`,
};
} catch (error) {
return {
ok: false,
detail: `外观件自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* 冒烟自检(不需要后端):关于页(/settings/about)。
* 断言页面渲染出标题与「鸣谢 / 诊断信息」区块,且 getAppInfo 返回了新增的 configPath
* (诊断信息依赖它)。
*/
export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
try {
const { router } = await import('../router/index.js');
await router.push('/settings/about');
const rendered = await waitUntil(
() => (document.querySelector('.page-title')?.textContent ?? '').includes('关于'),
6000,
);
if (!rendered) return { ok: false, detail: '关于页未挂载' };
const text = document.body.innerText.replace(/\s+/g, ' ');
const hasThanks = text.includes('鸣谢');
const hasDiagnostics = text.includes('诊断信息');
const info = await window.desktop?.getAppInfo?.();
const hasConfigPath = Boolean(info?.configPath);
await router.push('/schedule');
return {
ok: hasThanks && hasDiagnostics && hasConfigPath,
detail: `鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'}`,
};
} catch (error) {
return {
ok: false,
detail: `关于页自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = {
@@ -845,6 +1087,9 @@ export function registerSmokeHooks(): void {
islandRealtimeCleanup,
islandReadState,
islandHomeworkSuppressionCheck,
onboardingSelfTest,
layoutChromeSelfTest,
aboutPageSelfTest,
scheduleTimelineSelfTest,
homeworkBoardSelfTest,
sessionCleanup,
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { useOnboardingStore } from '../stores/onboarding.js';
/**
* 初次启动引导(六步向导)。
*
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
*/
const onboarding = useOnboardingStore();
const steps = [
{
icon: 'School',
title: '欢迎使用班级小助手',
lines: [
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
'第一次使用,先花半分钟看看怎么开始。',
],
},
{
icon: 'Link',
title: '第一步 · 连接服务器',
lines: [
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
],
},
{
icon: 'Key',
title: '第二步 · 登录班级',
lines: [
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
],
},
{
icon: 'Calendar',
title: '登录之后',
lines: [
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
],
},
{
icon: 'Bell',
title: '灵动岛提醒',
lines: [
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
],
},
{
icon: 'CircleCheckFilled',
title: '准备好了',
lines: [
'按上面的步骤登录就可以开始使用了。',
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
],
},
];
const index = ref(0);
const current = computed(() => steps[index.value] ?? steps[0]);
const isFirst = computed(() => index.value === 0);
const isLast = computed(() => index.value === steps.length - 1);
/** 每次打开都从第一步开始(重看入口也走完整流程) */
watch(
() => onboarding.visible,
(visible) => {
if (visible) index.value = 0;
},
);
function goNext(): void {
if (isLast.value) finish();
else index.value += 1;
}
function goPrev(): void {
if (!isFirst.value) index.value -= 1;
}
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
function finish(): void {
const bridge = window.desktop;
// 桥接不可用(浏览器调试)也要能正常关掉引导
if (!bridge) {
onboarding.close();
return;
}
void bridge
.saveConfig({ onboardingDone: true })
.then(() => onboarding.close())
.catch(() => onboarding.close());
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown);
}
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<Transition name="onboarding-fade">
<div
v-if="onboarding.visible"
class="onboarding-mask"
role="dialog"
aria-modal="true"
aria-label="初次启动引导"
>
<div class="onboarding-card onboarding-welcome">
<div class="onboarding-head">
<span class="onboarding-logo">
<el-icon :size="18"><School /></el-icon>
</span>
<div class="onboarding-heading">
<h2 class="onboarding-title">班级小助手</h2>
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
</div>
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
</div>
<div class="onboarding-body">
<span class="onboarding-icon">
<el-icon :size="30"><component :is="current.icon" /></el-icon>
</span>
<h3 class="onboarding-step-title">{{ current.title }}</h3>
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
{{ line }}
</p>
</div>
<div class="onboarding-foot">
<div class="onboarding-dots">
<span
v-for="(step, dotIndex) in steps"
:key="dotIndex"
class="dot"
:class="{ active: dotIndex === index }"
/>
</div>
<div class="onboarding-actions">
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
下一步
</el-button>
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.onboarding-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 30, 50, 0.55);
}
.onboarding-card {
width: 520px;
max-width: 92vw;
max-height: 86vh;
overflow-y: auto;
background: var(--ch-layer-solid);
border-radius: 16px;
padding: 22px 24px 18px;
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
}
.onboarding-head {
display: flex;
align-items: center;
gap: 10px;
}
.onboarding-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
}
.onboarding-heading {
flex: 1;
min-width: 0;
}
.onboarding-title {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
.onboarding-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--ch-text-tertiary);
}
.onboarding-body {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 18px 4px 6px;
}
.onboarding-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 16px;
margin-bottom: 14px;
color: var(--ch-accent);
background: var(--ch-accent-soft);
}
.onboarding-step-title {
margin: 0 0 10px;
font-size: 18px;
}
.onboarding-line {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.8;
color: var(--ch-text-regular, #606266);
}
.onboarding-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--ch-divider);
}
.onboarding-dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--ch-border-strong);
transition: all 0.2s ease;
}
.dot.active {
width: 20px;
background: var(--ch-accent);
}
.onboarding-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
.onboarding-fade-enter-active,
.onboarding-fade-leave-active {
transition: opacity 0.22s ease;
}
.onboarding-fade-enter-active .onboarding-card,
.onboarding-fade-leave-active .onboarding-card {
transition: transform 0.22s ease;
}
.onboarding-fade-enter-from,
.onboarding-fade-leave-to {
opacity: 0;
}
.onboarding-fade-enter-from .onboarding-card,
.onboarding-fade-leave-to .onboarding-card {
transform: translateY(10px) scale(0.98);
}
</style>
@@ -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 {
@@ -4,6 +4,8 @@ import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css';
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue';
import router from './router/index.js';
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/GradeView.vue'),
meta: { title: '成绩' },
},
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
{
path: 'settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: { title: '设置' },
redirect: '/settings/general',
},
{
path: 'settings/general',
name: 'settings-general',
component: () => import('../views/settings/GeneralSettingsView.vue'),
meta: { title: '通用设置' },
},
{
path: 'settings/appearance',
name: 'settings-appearance',
component: () => import('../views/settings/AppearanceView.vue'),
meta: { title: '外观' },
},
{
path: 'settings/island',
name: 'settings-island',
component: () => import('../views/settings/IslandSettingsView.vue'),
meta: { title: '灵动岛' },
},
{
path: 'settings/reminder',
name: 'settings-reminder',
component: () => import('../views/settings/ReminderSettingsView.vue'),
meta: { title: '提醒' },
},
{
path: 'settings/account',
name: 'settings-account',
component: () => import('../views/settings/AccountSettingsView.vue'),
meta: { title: '账号' },
},
{
path: 'settings/about',
name: 'settings-about',
component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' },
},
],
},
@@ -0,0 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 初次启动引导的显示状态。
*
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
*/
export const useOnboardingStore = defineStore('onboarding', () => {
const visible = ref(false);
function open(): void {
visible.value = true;
}
function close(): void {
visible.value = false;
}
return { visible, open, close };
});
@@ -0,0 +1,73 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js';
/**
* 界面偏好(主题 / 侧边栏折叠)。
*
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*/
const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> {
if (window.desktop) {
const config = await window.desktop.getConfig().catch(() => null);
if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true;
}
} else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
}
applyThemeClass(theme.value);
}
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
async function setTheme(next: 'light' | 'dark'): Promise<void> {
theme.value = next;
applyThemeClass(next);
if (window.desktop) {
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
} else {
localStorage.setItem(THEME_KEY, next);
}
}
function toggleTheme(): void {
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
sidebarCollapsed.value = collapsed;
if (window.desktop) {
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
} else {
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
}
}
function toggleSidebar(): void {
void setSidebarCollapsed(!sidebarCollapsed.value);
}
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
});
/** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
@@ -1,12 +1,19 @@
/* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
*
* 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
*
* 主题机制:
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
* ========================================================================== */
:root {
@@ -33,6 +40,13 @@
--ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a;
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
--ch-hover-soft: rgba(0, 0, 0, 0.04);
--ch-nav-bg: rgba(255, 255, 255, 0.65);
--ch-header-bg: rgba(255, 255, 255, 0.6);
--ch-scrollbar: rgba(0, 0, 0, 0.18);
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
@@ -62,6 +76,55 @@
--el-font-size-base: 13px;
}
/* ---------------------------------------------------------------- 深色主题 */
html.dark {
--ch-accent: #5aabec;
--ch-accent-hover: #7cc0f4;
--ch-accent-soft: rgba(90, 171, 236, 0.16);
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
--ch-bg: #161719;
--ch-layer: rgba(32, 33, 36, 0.72);
--ch-layer-solid: #1e1f23;
--ch-layer-alt: rgba(28, 29, 32, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.04);
--ch-border: rgba(255, 255, 255, 0.08);
--ch-border-strong: rgba(255, 255, 255, 0.14);
--ch-divider: rgba(255, 255, 255, 0.06);
--ch-text: #e8e8ea;
--ch-text-secondary: #b0b1b5;
--ch-text-tertiary: #7e7f85;
--ch-hover-soft: rgba(255, 255, 255, 0.07);
--ch-nav-bg: rgba(28, 29, 32, 0.65);
--ch-header-bg: rgba(30, 31, 34, 0.6);
--ch-scrollbar: rgba(255, 255, 255, 0.22);
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
--ch-sidebar: #1e1f23;
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
--el-color-primary: #4a9eff;
--el-color-primary-light-3: #337fd6;
--el-color-primary-light-5: #2563ab;
--el-color-primary-light-7: #1b4a82;
--el-color-primary-light-8: #163c6b;
--el-color-primary-light-9: #122f55;
--el-color-primary-dark-2: #6cb4ff;
--el-text-color-primary: #e8e8ea;
--el-text-color-regular: #c9cacd;
--el-text-color-secondary: #9a9b9f;
--el-border-color: rgba(255, 255, 255, 0.12);
--el-border-color-light: rgba(255, 255, 255, 0.08);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
}
* {
box-sizing: border-box;
}
@@ -179,12 +242,12 @@ textarea {
}
.el-button--default {
background: rgba(255, 255, 255, 0.7) !important;
background: var(--ch-layer) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: rgba(0, 0, 0, 0.04) !important;
background: var(--ch-hover-soft) !important;
}
.el-button--primary {
@@ -195,7 +258,7 @@ textarea {
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--ch-radius-control) !important;
background: rgba(255, 255, 255, 0.7) !important;
background: var(--ch-layer) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
}
@@ -224,12 +287,38 @@ textarea {
box-shadow: var(--ch-shadow-flyout) !important;
}
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
.el-collapse {
--el-collapse-border-color: var(--ch-divider);
border: none !important;
}
.el-collapse-item__header {
background: var(--ch-layer) !important;
border: 1px solid var(--ch-border) !important;
border-radius: var(--ch-radius-card) !important;
padding: 0 16px;
margin-top: 8px;
font-weight: 600;
font-size: 13.5px;
}
.el-collapse-item__wrap {
background: transparent !important;
border-bottom: none !important;
}
.el-collapse-item__content {
padding: 12px 6px 4px !important;
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
--el-table-row-hover-bg-color: var(--ch-hover-soft);
background: transparent !important;
}
@@ -251,12 +340,13 @@ textarea {
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav {
background: rgba(255, 255, 255, 0.65);
background: var(--ch-nav-bg);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item {
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
height: 40px;
line-height: 40px;
margin: 2px 8px;
@@ -266,8 +356,19 @@ textarea {
position: relative;
}
.ch-nav .el-menu-item:hover {
background: rgba(0, 0, 0, 0.04);
.ch-nav .el-menu-item:hover,
.ch-nav .el-sub-menu__title:hover {
background: var(--ch-hover-soft);
}
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
.ch-nav .el-sub-menu__title {
font-weight: 600;
color: var(--ch-text-secondary);
}
.ch-nav .el-sub-menu .el-menu {
background: transparent;
}
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
@@ -288,6 +389,58 @@ textarea {
background: var(--ch-accent);
}
/* 折叠态:图标居中(el-menu collapse 自带收窄,这里压掉分组缩进) */
.ch-nav .el-menu--collapse .el-menu-item,
.ch-nav .el-menu--collapse .el-sub-menu__title {
margin: 2px 8px;
padding: 0 !important;
}
/*
* 折叠态:把菜单文字**彻底不渲染**,只留图标槽。
*
* Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + overflow",一旦外层样式
* (缩进、margin、transition)稍有干扰,文字就会以 0 宽溢出的形式留在画面上 ——
* 表现为折叠后菜单里残留"半截字"。这里直接 display:none,从根上消除残影。
* 图标槽(.menu-icon-slot)必须保留(它同时承载未读红点)。
*/
.ch-nav .el-menu--collapse .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-menu--collapse .el-sub-menu__title > span:not(.menu-icon-slot) {
display: none !important;
}
/* 折叠态:图标在 64px 栏内严格水平居中,且与顶部的汉堡按钮同一竖列(中心都在 32px) */
.ch-nav .el-menu--collapse .el-menu-item,
.ch-nav .el-menu--collapse .el-sub-menu__title {
display: flex;
align-items: center;
justify-content: center;
}
/*
* 折叠态点分组图标会弹出子菜单浮层 —— 它被 teleport 到 body 下,
* 不受 .ch-nav 作用域影响,所以这些规则写在全局;不写的话深色主题下会是浅色浮层。
*/
.el-menu--popup {
background: var(--ch-layer-solid);
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
box-shadow: var(--ch-shadow-flyout);
}
.el-menu--popup .el-menu-item {
color: var(--ch-text);
}
.el-menu--popup .el-menu-item:hover {
background: var(--ch-hover-soft);
}
.el-menu--popup .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell {
@@ -320,14 +473,14 @@ textarea {
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
background: var(--ch-scrollbar);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background: var(--ch-scrollbar-hover);
background-clip: content-box;
}
@@ -226,7 +226,7 @@ onUnmounted(() => {
<style scoped>
.stat-card {
background: #fff;
background: var(--ch-layer-solid);
border: 1px solid var(--ch-border);
border-radius: 10px;
padding: 16px;
@@ -234,7 +234,7 @@ onUnmounted(() => {
.stat-label {
font-size: 13px;
color: #909399;
color: var(--ch-text-secondary);
}
.stat-value {
@@ -246,7 +246,7 @@ onUnmounted(() => {
.stat-hint {
margin-top: 4px;
font-size: 12px;
color: #c0c4cc;
color: var(--ch-text-tertiary);
}
.level-list {
@@ -916,7 +916,7 @@ onUnmounted(() => {
.content-block {
white-space: pre-wrap;
line-height: 1.75;
background: #fafafa;
background: var(--ch-layer-alt);
border-radius: 12px;
padding: 12px;
margin: 0;
@@ -1148,7 +1148,7 @@ onUnmounted(() => {
transform: translateX(-50%);
font-size: 14px;
font-weight: 500;
color: #6b7280;
color: var(--ch-text-tertiary);
white-space: nowrap;
}
@@ -1176,7 +1176,7 @@ onUnmounted(() => {
.submission-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid #ebeef5;
border: 1px solid var(--ch-divider);
border-radius: 12px;
padding: 12px;
}
@@ -5,12 +5,14 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useOnboardingStore } from '../stores/onboarding.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const formRef = ref<FormInstance>();
@@ -134,6 +136,14 @@ async function enterOffline(): Promise<void> {
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div>
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
<div class="login-guide">
<el-button link type="primary" @click="onboarding.open()">
<el-icon><Guide /></el-icon>
查看使用引导
</el-button>
</div>
<el-alert
v-if="testResult === 'ok'"
class="mt-12"
@@ -177,7 +187,7 @@ async function enterOffline(): Promise<void> {
.login-card {
width: 420px;
background: #fff;
background: var(--ch-layer-solid);
border-radius: 14px;
padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
@@ -198,7 +208,7 @@ async function enterOffline(): Promise<void> {
.login-sub {
margin: 4px 0 0;
font-size: 12px;
color: #909399;
color: var(--ch-text-secondary);
}
.login-actions {
@@ -210,6 +220,12 @@ async function enterOffline(): Promise<void> {
flex: 1;
}
.login-guide {
display: flex;
justify-content: center;
margin-top: 10px;
}
.offline-button {
width: 100%;
}
@@ -218,6 +234,6 @@ async function enterOffline(): Promise<void> {
margin: 16px 0 0;
font-size: 12px;
line-height: 1.7;
color: #c0c4cc;
color: var(--ch-text-tertiary);
}
</style>
@@ -165,11 +165,11 @@ onUnmounted(() => {
}
.notice-item:hover {
background: #f7f9fc;
background: var(--ch-hover-soft);
}
.notice-item.unread {
background: #f0f7ff;
background: var(--ch-accent-soft);
}
.notice-item:last-child {
@@ -194,7 +194,7 @@ onUnmounted(() => {
.notice-content {
margin-top: 6px;
font-size: 13px;
color: #606266;
color: var(--ch-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -220,7 +220,7 @@ onUnmounted(() => {
.content-block {
white-space: pre-wrap;
line-height: 1.8;
background: #fafafa;
background: var(--ch-layer-alt);
border-radius: 8px;
padding: 14px;
margin: 0 0 16px;
@@ -460,7 +460,7 @@ onUnmounted(() => {
}
.hero-status.status-idle {
background: rgba(0, 0, 0, 0.03);
background: var(--ch-hover-soft);
}
.status-title {
@@ -553,7 +553,7 @@ onUnmounted(() => {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
background: var(--ch-layer-solid);
border: 2px solid var(--ch-border-strong);
}
@@ -574,7 +574,8 @@ onUnmounted(() => {
padding: 10px 14px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: rgba(255, 255, 255, 0.6);
/* 走主题变量:浅色是白玻璃、深色是深玻璃(写死白色会在深色主题下变成"浅灰卡 + 浅字"看不清) */
background: var(--ch-layer);
transition:
border-color 0.15s ease,
background 0.15s ease,
@@ -595,7 +596,7 @@ onUnmounted(() => {
}
.timeline-item.is-past .timeline-card-inner {
background: rgba(255, 255, 255, 0.35);
background: var(--ch-layer-alt);
color: var(--ch-text-tertiary);
}
@@ -1,708 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
ISLAND_STYLE_LABELS,
ISLAND_STYLES,
ISLAND_POSITIONS,
formatDate,
type ClassIslandNotificationChannel,
type IslandAppearance,
type IslandPosition,
type IslandStyle,
} from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js';
import { classIslandStatusApi } from '../api/index.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.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';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function loadAppInfo(): Promise<void> {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo();
}
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
/* ------------------------------------------------------------ 通知显示位置 */
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
* - client:只弹 ClassHelper 客户端
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
*
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
*/
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function loadClassChannel(): Promise<void> {
const classId = auth.classId;
if (!classId) return;
await appStore.loadNotificationChannel(classId);
}
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
/* ------------------------------------------------------------ 作业录入快捷短语 */
/**
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
*
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
*/
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
const phraseInput = ref('');
const savingPhrases = ref(false);
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
async function savePhrases(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingPhrases.value = true;
try {
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
phrases.value = [...saved.homeworkPhrases];
} finally {
savingPhrases.value = false;
}
}
async function addPhrase(): Promise<void> {
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value) return;
if (phrases.value.includes(value)) {
ElMessage.warning('这条短语已经有了');
return;
}
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
return;
}
phrases.value = [...phrases.value, value];
phraseInput.value = '';
await savePhrases();
ElMessage.success('已添加');
}
async function removePhrase(value: string): Promise<void> {
phrases.value = phrases.value.filter((item) => item !== value);
await savePhrases();
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
}
/* ------------------------------------------------------------ 灵动岛个性化 */
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
*
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
*/
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
const savingIsland = ref(false);
const positionOptions = ISLAND_POSITIONS.map((value) => ({
value,
label: ISLAND_POSITION_LABELS[value],
}));
const styleOptions = ISLAND_STYLES.map((value) => ({
value,
label: ISLAND_STYLE_LABELS[value],
}));
/** 读取当前生效的外观(主进程为单一事实来源) */
async function loadIslandAppearance(): Promise<void> {
if (!window.desktop?.islandGetAppearance) return;
island.value = await window.desktop.islandGetAppearance();
}
/**
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
*
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
*/
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance({ ...island.value });
}
async function saveIslandAppearance(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingIsland.value = true;
try {
await window.desktop.saveConfig({ island: { ...island.value } });
ElMessage.success('个性化设置已保存');
} finally {
savingIsland.value = false;
}
}
async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance();
}
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
function testIsland(): void {
window.desktop?.islandPush?.({
notification: {
id: `appearance-test-${Date.now()}`,
title: '灵动岛外观预览',
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
priority: 'NORMAL',
createdAt: new Date().toISOString(),
courseName: null,
teacherName: '本地预览',
},
context: { inClass: false, currentPeriodEnd: null, preview: true },
});
}
onMounted(async () => {
await loadAppInfo();
await appStore.refreshCacheStats();
await loadIslandAppearance();
await loadClassChannel();
await loadPhrases();
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">设置</h2>
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header>
<span>灵动岛 · 个性化</span>
</template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
style="width: 180px"
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
>
<el-option
v-for="item in positionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="`左右边距 ${island.marginX}px`">
<el-slider
:model-value="island.marginX"
:min="RANGES.marginX.min"
:max="RANGES.marginX.max"
:step="2"
:disabled="!marginXApplies"
@input="(value: number) => applyIslandAppearance({ marginX: value })"
/>
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
<span v-else class="text-muted ml-8">
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
</span>
</el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider
:model-value="island.marginY"
:min="RANGES.marginY.min"
:max="RANGES.marginY.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginY: value })"
/>
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
</el-form-item>
<el-form-item label="跟随鼠标屏幕">
<el-switch
:model-value="island.followCursorDisplay"
@change="
(value: boolean | string | number) =>
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
"
/>
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@change="
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
"
/>
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
</el-form-item>
<el-form-item label="始终置顶">
<el-switch
:model-value="island.alwaysOnTop"
@change="
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
"
/>
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
保存设置
</el-button>
<el-button @click="testIsland">预览效果</el-button>
<el-button @click="resetIslandAppearance">恢复默认</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted">
<strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio
v-for="item in channelOptions"
:key="item.value"
:value="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
</p>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template>
<p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p>
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
closable
type="info"
effect="plain"
@close="removePhrase(item)"
>
{{ item }}
</el-tag>
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
</div>
<div class="toolbar mt-12">
<el-input
v-model="phraseInput"
placeholder="新增短语,如 大本"
style="width: 200px"
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
@keyup.enter="addPhrase"
/>
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>客户端信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.channel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
}
.channel-hint {
margin-top: 10px;
}
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
/**
* 关于(ClassIsland 同款折叠卡片式):
* 应用信息(版本/运行时/常用链接)→ 诊断信息(排查问题用)→ 鸣谢。
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
*/
const appStore = useAppStore();
const appInfo = ref<DesktopAppInfo | null>(null);
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
const expandedNames = ref(['app']);
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
const collapseItems = [
{ name: 'app', title: '应用信息', icon: 'School' },
{ name: 'diagnostics', title: '查看诊断信息', icon: 'Warning' },
{ name: 'thanks', title: '鸣谢', icon: 'Pointer' },
];
async function openExternal(url: string): Promise<void> {
if (window.desktop?.openExternal) {
const handled = await window.desktop.openExternal(url);
if (handled) return;
}
window.open(url, '_blank');
}
onMounted(async () => {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">关于</h2>
<p class="page-subtitle">版本、诊断信息与鸣谢</p>
</div>
</div>
<el-collapse v-model="expandedNames" class="about-collapse">
<!-- 应用信息 -->
<el-collapse-item name="app">
<template #title>
<span class="about-title">
<el-icon><School /></el-icon>
{{ collapseItems[0].title }}
</span>
<span class="about-extra">
班级小助手 v{{ appInfo?.appVersion ?? '-' }}
</span>
</template>
<div class="about-section">
<p class="about-copy">
Copyright © 2025-2026 班级小助手
<br />
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
</p>
<div class="about-links">
<span class="about-links-label">常用链接</span>
<el-button link type="primary" @click="openExternal(PROJECT_URL)">项目主页</el-button>
<el-button link type="primary" @click="openExternal(ISSUES_URL)">问题反馈</el-button>
</div>
<el-descriptions :column="1" border size="small" class="mt-12">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="运行时">
Electron {{ appInfo?.electron ?? '-' }} · Chromium {{ appInfo?.chrome ?? '-' }} · Node
{{ appInfo?.node ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-collapse-item>
<!-- 诊断信息 -->
<el-collapse-item name="diagnostics">
<template #title>
<span class="about-title">
<el-icon><Warning /></el-icon>
{{ collapseItems[1].title }}
</span>
<span class="about-extra">报障时请附带本页信息</span>
</template>
<div class="about-section">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="服务器地址">{{ appStore.serverUrl }}</el-descriptions-item>
<el-descriptions-item label="连接状态">
<el-tag size="small" :type="appStore.serverReachable ? 'success' : 'danger'">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="缓存条目">{{ appStore.cacheItemCount }} 条</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="配置文件">{{ appInfo?.configPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-collapse-item>
<!-- 鸣谢 -->
<el-collapse-item name="thanks">
<template #title>
<span class="about-title">
<el-icon><Pointer /></el-icon>
{{ collapseItems[2].title }}
</span>
</template>
<div class="about-section">
<ul class="about-thanks">
<li><strong>ClassIsland</strong> —— 界面风格、设置页与「今天」时间轴的交互参考</li>
<li><strong>WinIsland</strong> —— 灵动岛的窗口形态与弹簧动画参考</li>
<li><strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong> —— 本项目赖以构建的开源基石</li>
</ul>
</div>
</el-collapse-item>
</el-collapse>
<p class="about-quote">谨以此应用,献给朝夕相伴的班级与同窗;愿时光流转,情谊常在。</p>
</div>
</template>
<style scoped>
.about-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.about-extra {
margin-left: auto;
margin-right: 12px;
font-size: 12px;
font-weight: 400;
color: var(--ch-text-tertiary);
}
.about-section {
padding: 0 4px;
}
.about-copy {
margin: 0 0 10px;
line-height: 1.8;
color: var(--ch-text-secondary);
font-size: 12.5px;
}
.about-links {
display: flex;
align-items: center;
gap: 4px;
}
.about-links-label {
font-size: 12.5px;
color: var(--ch-text-secondary);
margin-right: 4px;
}
.about-thanks {
margin: 0;
padding-left: 18px;
line-height: 2;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.about-quote {
margin: 18px 0 4px;
text-align: center;
font-size: 12px;
color: var(--ch-text-tertiary);
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '../../stores/auth.js';
import { useRealtimeStore } from '../../stores/realtime.js';
/**
* 账号设置:本机登录的班级账号信息 + 退出登录。
* 班级密码由管理员在 Web 管理端维护(本机按用户要求不提供改密入口)。
*/
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">账号</h2>
<p class="page-subtitle">本机登录的班级账号</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { useUiStore } from '../../stores/ui.js';
/**
* 外观:黑夜 / 白天模式。
* 状态与持久化都在 stores/ui.ts(主进程配置 theme 字段),顶栏右上角的
* 日月按钮与本页写同一份配置,切换立即生效(html.dark + Element Plus dark css-vars)。
*/
const ui = useUiStore();
const options = [
{ value: 'light', label: '浅色', desc: '明亮界面,适合白天与投影环境' },
{ value: 'dark', label: '深色', desc: '黑夜模式,适合晚自习与暗教室' },
] as const;
async function selectTheme(value: 'light' | 'dark'): Promise<void> {
if (value === ui.theme) return;
await ui.setTheme(value);
ElMessage.success(value === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">外观</h2>
<p class="page-subtitle">黑夜与白天模式,切换立即生效并自动保存</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>界面主题</span></template>
<div class="theme-options">
<button
v-for="option in options"
:key="option.value"
type="button"
class="theme-option"
:class="[`theme-${option.value}`, { active: ui.theme === option.value }]"
:data-theme-option="option.value"
@click="selectTheme(option.value)"
>
<span class="theme-preview">
<span class="preview-side" />
<span class="preview-body">
<span class="preview-line" />
<span class="preview-line short" />
</span>
</span>
<span class="theme-name">
<el-icon><Sunny v-if="option.value === 'light'" /><Moon v-else /></el-icon>
{{ option.label }}
</span>
<span class="theme-desc">{{ option.desc }}</span>
</button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="顶栏右上角也有主题快捷切换按钮"
description="灵动岛始终为深色浮窗,不随主题变化;登录页与全屏作业看板为固定配色,亦不受影响。"
/>
</el-card>
</div>
</template>
<style scoped>
.theme-options {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.theme-option {
width: 200px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 12px;
border: 1px solid var(--ch-border-strong);
border-radius: var(--ch-radius-card);
background: var(--ch-layer);
cursor: pointer;
color: var(--ch-text);
text-align: left;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.theme-option:hover {
border-color: var(--ch-accent);
}
.theme-option.active {
border-color: var(--ch-accent);
box-shadow: 0 0 0 1px var(--ch-accent) inset;
}
/* 主题缩略预览:两块小色板直接表现各自配色 */
.theme-preview {
display: flex;
width: 100%;
height: 64px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--ch-border-strong);
}
.theme-light .theme-preview {
background: #f3f3f3;
}
.theme-dark .theme-preview {
background: #161719;
}
.preview-side {
width: 28px;
background: rgba(255, 255, 255, 0.65);
}
.theme-dark .preview-side {
background: rgba(32, 33, 36, 0.9);
}
.preview-body {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
height: 8px;
border-radius: 4px;
width: 80%;
background: rgba(15, 108, 189, 0.55);
}
.preview-line.short {
width: 50%;
background: rgba(128, 128, 128, 0.35);
}
.theme-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.theme-desc {
font-size: 12px;
color: var(--ch-text-tertiary);
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
formatDate,
} from '@classhelper/shared';
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
import { normalizeServerUrl } from '../../config.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js';
import { useNotificationStore } from '../../stores/notifications.js';
import { useOnboardingStore } from '../../stores/onboarding.js';
import { useRealtimeStore } from '../../stores/realtime.js';
/**
* 通用设置:服务器连接、离线缓存与作业录入短语。
* (外观 / 灵动岛 / 提醒 / 账号 / 关于 各自是独立子页,见侧边栏「设置」分组。)
*/
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
/* ------------------------------------------------------------ 作业录入快捷短语 */
/**
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
*
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
*/
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
const phraseInput = ref('');
const savingPhrases = ref(false);
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
async function savePhrases(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingPhrases.value = true;
try {
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
phrases.value = [...saved.homeworkPhrases];
} finally {
savingPhrases.value = false;
}
}
async function addPhrase(): Promise<void> {
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value) return;
if (phrases.value.includes(value)) {
ElMessage.warning('这条短语已经有了');
return;
}
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
return;
}
phrases.value = [...phrases.value, value];
phraseInput.value = '';
await savePhrases();
ElMessage.success('已添加');
}
async function removePhrase(value: string): Promise<void> {
phrases.value = phrases.value.filter((item) => item !== value);
await savePhrases();
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
}
onMounted(async () => {
await appStore.refreshCacheStats();
await loadPhrases();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通用</h2>
<p class="page-subtitle">服务器连接、离线缓存与作业录入</p>
</div>
<!-- 重看初次启动引导(首启时由 App.vue 自动弹出过一次) -->
<el-button @click="onboarding.open()">
<el-icon><Guide /></el-icon>
使用引导
</el-button>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template>
<p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p>
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
closable
type="info"
effect="plain"
@close="removePhrase(item)"
>
{{ item }}
</el-tag>
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
</div>
<div class="toolbar mt-12">
<el-input
v-model="phraseInput"
placeholder="新增短语,如 大本"
style="width: 200px"
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
@keyup.enter="addPhrase"
/>
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,276 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
ISLAND_POSITIONS,
ISLAND_STYLE_LABELS,
ISLAND_STYLES,
type IslandAppearance,
type IslandPosition,
type IslandStyle,
} from '@classhelper/shared';
/**
* 灵动岛个性化(原设置页「灵动岛 · 个性化」卡整块迁移)。
*
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
*/
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
*
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
*/
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
const positionOptions = ISLAND_POSITIONS.map((value) => ({
value,
label: ISLAND_POSITION_LABELS[value],
}));
const styleOptions = ISLAND_STYLES.map((value) => ({
value,
label: ISLAND_STYLE_LABELS[value],
}));
/** 读取当前生效的外观(主进程为单一事实来源) */
async function loadIslandAppearance(): Promise<void> {
if (!window.desktop?.islandGetAppearance) return;
island.value = await window.desktop.islandGetAppearance();
}
/** 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘 */
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance({ ...island.value });
}
async function saveIslandAppearance(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingIsland.value = true;
try {
await window.desktop.saveConfig({ island: { ...island.value } });
ElMessage.success('个性化设置已保存');
} finally {
savingIsland.value = false;
}
}
async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance();
}
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
function testIsland(): void {
window.desktop?.islandPush?.({
notification: {
id: `appearance-test-${Date.now()}`,
title: '灵动岛外观预览',
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
priority: 'NORMAL',
createdAt: new Date().toISOString(),
courseName: null,
teacherName: '本地预览',
},
context: { inClass: false, currentPeriodEnd: null, preview: true },
});
}
onMounted(async () => {
await loadIslandAppearance();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">灵动岛</h2>
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>灵动岛 · 个性化</span></template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
style="width: 180px"
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
>
<el-option
v-for="item in positionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="`左右边距 ${island.marginX}px`">
<el-slider
:model-value="island.marginX"
:min="RANGES.marginX.min"
:max="RANGES.marginX.max"
:step="2"
:disabled="!marginXApplies"
@input="(value: number) => applyIslandAppearance({ marginX: value })"
/>
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
<span v-else class="text-muted ml-8">
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
</span>
</el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider
:model-value="island.marginY"
:min="RANGES.marginY.min"
:max="RANGES.marginY.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginY: value })"
/>
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
</el-form-item>
<el-form-item label="跟随鼠标屏幕">
<el-switch
:model-value="island.followCursorDisplay"
@change="
(value: boolean | string | number) =>
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
"
/>
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@change="
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
"
/>
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
</el-form-item>
<el-form-item label="始终置顶">
<el-switch
:model-value="island.alwaysOnTop"
@change="
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
"
/>
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">保存设置</el-button>
<el-button @click="testIsland">预览效果</el-button>
<el-button @click="resetIslandAppearance">恢复默认</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
</div>
</template>
<style scoped>
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { classIslandStatusApi } from '../../api/index.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js';
/**
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
*/
const appStore = useAppStore();
const auth = useAuthStore();
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
onMounted(async () => {
await appStore.loadNotificationChannel(auth.classId);
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">提醒</h2>
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted">
<strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
</p>
</el-card>
</div>
</template>
<style scoped>
.channel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
}
.channel-hint {
margin-top: 10px;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+19
View File
@@ -47,6 +47,17 @@ export interface DesktopStoredConfig {
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/
notificationChannel: ClassIslandNotificationChannel;
/**
* 是否已看过「初次启动引导」。
*
* 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出);
* 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。
*/
onboardingDone: boolean;
/** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */
theme: 'light' | 'dark';
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
sidebarCollapsed: boolean;
}
export interface DesktopAppInfo {
@@ -56,6 +67,8 @@ export interface DesktopAppInfo {
node: string;
platform: string;
userDataPath: string;
/** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */
configPath: string;
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
smokeTest: boolean;
}
@@ -209,6 +222,12 @@ declare global {
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
/** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */
onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */
layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */
aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -25,6 +25,7 @@
"@classhelper/shared": "workspace:*",
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3",
"compression": "^1.8.2",
@@ -34,7 +35,9 @@
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5",
"zod": "^4.6.2"
},
@@ -182,7 +182,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '0.1.0.0',
pluginVersion: '1.0.0.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
+224
View File
@@ -2339,6 +2339,230 @@ async function main() {
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
);
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
'数据库状态(管理员):已连接且返回 14 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
}`,
);
const dbStatusTeacher = await api('/database/status', { token: teacherToken });
const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken });
const dbScheduleTeacher = await api('/database/backup-schedule', {
method: 'PUT',
token: teacherToken,
body: { enabled: true, intervalHours: 24, keepCount: 7 },
});
const dbSwitchTeacher = await api('/database/switch', {
method: 'POST',
token: teacherToken,
body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' },
});
record(
'数据库管理越权(教师全部 403)',
dbStatusTeacher.status === 403 &&
dbBackupTeacher.status === 403 &&
dbScheduleTeacher.status === 403 &&
dbSwitchTeacher.status === 403,
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
);
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
let testOkResult;
let testOkName;
if (dbStatusData.provider === 'sqlite') {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
});
testOkName = '连接测试(当前 SQLite 库可达)';
} else {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' },
});
testOkName = '连接测试(不可达 MySQL 返回 ok=false)';
}
record(
testOkName,
testOkResult.status === 200 &&
(dbStatusData.provider === 'sqlite'
? testOkResult.payload?.data?.ok === true
: testOkResult.payload?.data?.ok === false),
`ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`,
);
const dbTestRedis = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)',
dbTestRedis.status === 422,
`status=${dbTestRedis.status}`,
);
const badConnection = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' },
});
record(
'连接测试(不可达 MySQL → ok=false 且不抛 500)',
badConnection.status === 200 && badConnection.payload?.data?.ok === false,
`ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`,
);
// 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env)
const switchSame =
dbStatusData.provider === 'sqlite'
? await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
})
: { status: 0, payload: null };
const switchInvalid = await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)',
(dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422,
`同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`,
);
// 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验)
const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken });
const backupName = dbBackup.payload?.data?.name ?? '';
record(
'手动备份(201,文件名符合约定格式)',
dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName),
`name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`,
);
const dbExport = await fetch(`${BASE_URL}/api/database/export`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const snapshotJson = dbExport.status === 200 ? await dbExport.text() : '';
let snapshotParsed = null;
try {
snapshotParsed = JSON.parse(snapshotJson);
} catch {
snapshotParsed = null;
}
record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
const dbRoundtripNotice = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' },
});
const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id;
const dbImport = await api('/database/import', {
method: 'POST',
token: adminToken,
body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') },
});
const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken });
const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNoticeId,
);
record(
'快照导入(整库覆盖:导入后临时通知消失)',
dbImport.status === 200 && roundtripNoticeGone,
`导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`,
);
const dbRoundtripNotice2 = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
});
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
const dbRestore = await api(`/database/backups/${backupName}/restore`, { method: 'POST', token: adminToken });
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNotice2Id,
);
record(
'从备份恢复(整库覆盖:恢复后临时通知消失)',
dbRestore.status === 200 && restoreNoticeGone,
`恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`,
);
if (dbRoundtripNoticeId) {
await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken });
}
if (dbRoundtripNotice2Id) {
await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken });
}
const dbBackupList = await api('/database/backups', { token: adminToken });
const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken });
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
record(
'备份列表与删除(创建后可见,删除后消失)',
dbBackupList.status === 200 &&
(dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) &&
dbBackupDelete.status === 200 &&
!(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName),
`删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`,
);
const dbScheduleInvalid = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: true, intervalHours: 0, keepCount: 7 },
});
const dbScheduleSave = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: false, intervalHours: 24, keepCount: 7 },
});
record(
'定时备份配置(非法间隔 422 / 合法保存并回读)',
dbScheduleInvalid.status === 422 &&
dbScheduleSave.status === 200 &&
dbScheduleSave.payload?.data?.enabled === false &&
dbScheduleSave.payload?.data?.intervalHours === 24,
`非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`,
);
if (dbStatusData.provider === 'sqlite') {
const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0;
record(
'SQLite 数据文件下载(快捷导出 .db 文件)',
sqliteFile.status === 200 && sqliteBytes > 0,
`status=${sqliteFile.status} bytes=${sqliteBytes}`,
);
}
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
const realtimeNoticeId = createdNotification.payload?.data?.id;
if (realtimeNoticeId) {
+3 -3
View File
@@ -37,7 +37,7 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
res,
{
status: 'ok',
version: '0.1.0',
version: '1.0.0',
env: env.nodeEnv,
uptimeSeconds: Math.round(process.uptime()),
database: {
@@ -68,7 +68,7 @@ function createRootHandler(webDistDir: string | null) {
res,
{
service: 'class-helper-server',
version: '0.1.0',
version: '1.0.0',
environment: env.nodeEnv,
apiPrefix: API_PREFIX,
socket: '/socket.io',
@@ -115,7 +115,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<body>
<div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">版本 1.0.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
存活 /healthz · 就绪 /readyz
+3
View File
@@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js';
import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js';
/** 数据库自检与可选的首次初始化 */
async function prepareDatabase(): Promise<void> {
@@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => {
}
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
startBackupScheduler();
});
let shuttingDown = false;
+15 -9
View File
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
export type DatabaseProvider = 'sqlite' | 'mysql';
/**
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
* - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
*
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
* 也不会影响类型检查与 SQLite 模式的运行。
*
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
*/
async function createAdapter(): Promise<unknown> {
if (env.databaseProvider === 'mysql') {
export async function createPrismaClient(
provider: DatabaseProvider,
url: string,
): Promise<PrismaClient> {
if (provider === 'mysql') {
const adapterModule = '@prisma/adapter-mariadb';
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
logger.info('已加载 MySQL/MariaDB 驱动适配器');
return new mod.PrismaMariaDb(env.databaseUrl);
return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
}
return new PrismaLibSql({ url: env.databaseUrl });
return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
}
const adapter = await createAdapter();
export const prisma = new PrismaClient({ adapter: adapter as never });
export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
+159
View File
@@ -0,0 +1,159 @@
import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js';
/**
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
*
* 为什么不用「复制数据库文件」或 mysqldump:
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
*
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
*/
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
export const SNAPSHOT_VERSION = 1;
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
export const SNAPSHOT_TABLES = [
'User',
'Class',
'Course',
'ClassTeacher',
'Enrollment',
'Schedule',
'TimeLayout',
'Homework',
'HomeworkStatus',
'Notification',
'NotificationRead',
'Grade',
'IntegrationDevice',
'ClassIslandPush',
] as const;
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
export interface DatabaseSnapshot {
format: string;
version: number;
createdAt: string;
source: { provider: string };
/** 每张表的行数(导出时统计,恢复时校验总量用) */
counts: Record<string, number>;
/** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */
data: Record<SnapshotTable, Record<string, unknown>[]>;
}
/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */
interface ModelDelegate {
findMany(): Promise<Record<string, unknown>[]>;
count(): Promise<number>;
createMany(args: { data: Record<string, unknown>[] }): Promise<unknown>;
deleteMany(): Promise<unknown>;
}
function delegatesOf(prisma: PrismaClient): Record<SnapshotTable, ModelDelegate> {
return prisma as unknown as Record<SnapshotTable, ModelDelegate>;
}
/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */
const CHUNK_SIZE = 200;
/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */
export async function exportSnapshot(
prisma: PrismaClient,
provider: DatabaseProvider,
): Promise<DatabaseSnapshot> {
const delegates = delegatesOf(prisma);
const data = {} as Record<SnapshotTable, Record<string, unknown>[]>;
const counts: Record<string, number> = {};
for (const table of SNAPSHOT_TABLES) {
const rows = await delegates[table].findMany();
data[table] = rows;
counts[table] = rows.length;
}
return {
format: SNAPSHOT_FORMAT,
version: SNAPSHOT_VERSION,
createdAt: new Date().toISOString(),
source: { provider },
counts,
data,
};
}
/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */
export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot {
if (typeof value !== 'object' || value === null) {
throw new Error('快照内容不是合法的 JSON 对象');
}
const snapshot = value as Partial<DatabaseSnapshot>;
if (snapshot.format !== SNAPSHOT_FORMAT) {
throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`);
}
if (snapshot.version !== SNAPSHOT_VERSION) {
throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`);
}
if (typeof snapshot.data !== 'object' || snapshot.data === null) {
throw new Error('快照缺少 data 字段');
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record<string, unknown>)[table];
if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`);
}
}
}
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
const sql =
provider === 'mysql'
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
: `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`;
await prisma.$executeRawUnsafe(sql);
}
/**
* 把快照写入目标库(先清空再写入)。
* 用于:备份恢复、快照导入、跨库迁移的子进程。
*/
export async function restoreSnapshot(
prisma: PrismaClient,
provider: DatabaseProvider,
snapshot: DatabaseSnapshot,
options: { clean?: boolean } = {},
): Promise<{ counts: Record<string, number> }> {
assertValidSnapshot(snapshot);
const delegates = delegatesOf(prisma);
const clean = options.clean !== false;
const counts: Record<string, number> = {};
await setForeignKeys(prisma, provider, false);
try {
if (clean) {
// 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性)
for (const table of [...SNAPSHOT_TABLES].reverse()) {
await delegates[table].deleteMany();
}
}
for (const table of SNAPSHOT_TABLES) {
const rows = snapshot.data[table];
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
}
counts[table] = rows.length;
}
} finally {
await setForeignKeys(prisma, provider, true).catch(() => undefined);
}
return { counts };
}
/** 汇总快照总行数(列表展示 / 切换任务结果用) */
export function snapshotTotalRows(snapshot: DatabaseSnapshot): number {
return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);
}
@@ -0,0 +1,124 @@
import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
backupNameParamSchema,
backupScheduleSchema,
importSnapshotSchema,
jobIdParamSchema,
switchDatabaseSchema,
testConnectionSchema,
type BackupScheduleInput,
type ImportSnapshotInput,
type SwitchDatabaseInput,
type TestConnectionInput,
} from './database.schemas.js';
import * as databaseService from './database.service.js';
/**
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
*
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
* Redis 是键值库,不是 Prisma 支持的主数据库,因此不在此模块范围内。
*/
const router = Router();
router.use(authenticate());
router.use(requireRole('ADMIN'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
sendOk(res, await databaseService.getDatabaseStatus());
});
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
});
/** GET /api/database/backups - 备份列表 */
router.get('/backups', (_req, res) => {
sendOk(res, databaseService.listBackups());
});
/** POST /api/database/backups - 立即创建一份手动备份 */
router.post('/backups', async (_req, res) => {
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
});
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
});
/** DELETE /api/database/backups/:name - 删除备份 */
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
databaseService.deleteBackup(name);
sendOk(res, { name }, '备份已删除');
});
/** GET /api/database/export - 下载当前库的 JSON 快照 */
router.get('/export', async (_req, res) => {
const json = await databaseService.exportSnapshotJson();
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.status(200).send(json);
});
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
router.get('/sqlite-file', (_req, res) => {
const filePath = databaseService.sqliteFilePathForDownload();
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
res.status(200).send(fs.readFileSync(filePath));
});
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
const { data } = validatedBody<ImportSnapshotInput>(req);
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
});
/** GET /api/database/backup-schedule - 定时备份配置 */
router.get('/backup-schedule', (_req, res) => {
sendOk(res, databaseService.getBackupSchedule());
});
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
sendOk(
res,
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
'定时备份配置已保存',
);
});
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
sendCreated(res, { jobId: job.id }, '切换任务已启动');
});
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const job = databaseService.getSwitchJob(id);
sendOk(res, job, job ? 'ok' : '任务不存在');
});
export const databaseModule = defineModule({
name: 'database',
basePath: '/database',
router,
});
@@ -0,0 +1,65 @@
import { z } from 'zod';
/** 本项目支持的主数据库类型。Redis 等键值库不是 Prisma 支持的主库,不提供切换。 */
export const databaseProviderSchema = z.enum(['sqlite', 'mysql']);
export type DatabaseProviderInput = z.infer<typeof databaseProviderSchema>;
/** 连接串与 provider 配套校验:SQLite 必须 file: 前缀,MySQL 必须 mysql:// 前缀 */
export const targetConnectionSchema = z
.object({
provider: databaseProviderSchema,
url: z.string().min(1, '请填写连接串'),
})
.superRefine((value, ctx) => {
const url = value.url.trim();
if (value.provider === 'sqlite' && !url.startsWith('file:')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'SQLite 连接串必须以 file: 开头,例如 file:./data/classhelper.db',
});
}
if (value.provider === 'mysql' && !/^mysqls?:\/\//.test(url)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'MySQL 连接串必须以 mysql:// 或 mysqls:// 开头',
});
}
});
export type TargetConnectionInput = z.infer<typeof targetConnectionSchema>;
/** POST /database/test-connection:测试任意目标库的连通性 */
export const testConnectionSchema = targetConnectionSchema;
export type TestConnectionInput = z.infer<typeof testConnectionSchema>;
/** POST /database/switch:一键切换数据库(含数据迁移) */
export const switchDatabaseSchema = targetConnectionSchema;
export type SwitchDatabaseInput = z.infer<typeof switchDatabaseSchema>;
/** POST /database/import:上传快照(base64,随 JSON 请求体,受 12MB 请求体限制) */
export const importSnapshotSchema = z.object({
data: z.string().min(1, '请提供快照内容(base64)'),
});
export type ImportSnapshotInput = z.infer<typeof importSnapshotSchema>;
/** PUT /database/backup-schedule:定时备份配置 */
export const backupScheduleSchema = z.object({
enabled: z.boolean(),
/** 间隔小时数(1~720,默认 24 = 每天一次) */
intervalHours: z.number().int().min(1).max(720),
/** 最多保留多少份自动备份(超出后删最旧的;手动备份不受影响) */
keepCount: z.number().int().min(1).max(100),
});
export type BackupScheduleInput = z.infer<typeof backupScheduleSchema>;
/** 路径参数:备份文件名(必须是本服务生成的固定格式,杜绝路径穿越) */
export const backupNameParamSchema = z.object({
name: z.string().regex(/^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/, '备份文件名不合法'),
});
/** 路径参数:切换任务 id(本服务生成,字母数字与连字符) */
export const jobIdParamSchema = z.object({
id: z.string().regex(/^[0-9a-z-]{6,40}$/, '任务 id 不合法'),
});
@@ -0,0 +1,771 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { env } from '../../config/env.js';
import { createPrismaClient, prisma, type DatabaseProvider } from '../../lib/db.js';
import { logger } from '../../lib/logger.js';
import {
assertValidSnapshot,
exportSnapshot,
restoreSnapshot,
SNAPSHOT_TABLES,
type DatabaseSnapshot,
} from '../../lib/snapshot.js';
import { ApiError } from '../../lib/http.js';
import type {
BackupScheduleInput,
TargetConnectionInput,
} from './database.schemas.js';
/**
* 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 所有实现都基于 `lib/snapshot.ts` 的 JSON 快照:与 provider 无关,
* 因此备份可以恢复回任意一种受支持的数据库(备份即迁移的载体)。
*/
/* ================================================================ 路径 */
/** 数据目录:SQLite = 数据文件所在目录;MySQL = 安装根的 data 目录(与部署约定一致) */
function dataDir(): string {
if (env.databaseProvider === 'sqlite' && env.sqliteFilePath) {
return path.dirname(env.sqliteFilePath);
}
return path.join(env.serverRoot, '..', 'data');
}
function backupDir(): string {
return path.join(dataDir(), 'backups');
}
function settingsFilePath(): string {
return path.join(dataDir(), 'database-settings.json');
}
function ensureBackupDir(): string {
const dir = backupDir();
fs.mkdirSync(dir, { recursive: true });
return dir;
}
/* ================================================================ 定时备份配置 */
interface BackupSchedule {
enabled: boolean;
intervalHours: number;
keepCount: number;
lastAutoBackupAt: string | null;
}
const DEFAULT_SCHEDULE: BackupSchedule = {
enabled: false,
intervalHours: 24,
keepCount: 7,
lastAutoBackupAt: null,
};
function readSchedule(): BackupSchedule {
try {
const raw = JSON.parse(fs.readFileSync(settingsFilePath(), 'utf8')) as Partial<BackupSchedule>;
return {
enabled: raw.enabled === true,
intervalHours:
typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours,
keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount,
lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null,
};
} catch {
return { ...DEFAULT_SCHEDULE };
}
}
function writeSchedule(schedule: BackupSchedule): void {
ensureBackupDir();
fs.writeFileSync(settingsFilePath(), `${JSON.stringify(schedule, null, 2)}\n`, 'utf8');
}
/** 读取定时备份配置(GET /backup-schedule) */
export function getBackupSchedule(): BackupSchedule {
return readSchedule();
}
/** 更新定时备份配置(PUT /backup-schedule),写盘后立即生效 */
export function saveSchedulePatch(patch: BackupScheduleInput): BackupSchedule {
const next: BackupSchedule = { ...readSchedule(), ...patch };
writeSchedule(next);
return next;
}
let schedulerTimer: NodeJS.Timeout | null = null;
/**
* 定时备份调度:每 10 分钟检查一次,到点自动备份并清理旧备份。
* 依赖服务端进程常驻(进程不运行不会补跑);lastAutoBackupAt 持久化在配置文件里,
* 重启后不会立刻重复备份。
*/
export function startBackupScheduler(): void {
if (schedulerTimer) return;
const tick = async (): Promise<void> => {
if (isSwitchRunning()) return;
const schedule = readSchedule();
if (!schedule.enabled) return;
const last = schedule.lastAutoBackupAt ? Date.parse(schedule.lastAutoBackupAt) : 0;
const dueAt = (Number.isFinite(last) ? last : 0) + schedule.intervalHours * 3_600_000;
if (Date.now() < dueAt) return;
try {
await createBackup('auto');
writeSchedule({ ...schedule, lastAutoBackupAt: new Date().toISOString() });
logger.info('定时备份完成');
} catch (error) {
logger.error('定时备份失败', error);
}
};
schedulerTimer = setInterval(() => void tick(), 10 * 60_000);
schedulerTimer.unref();
logger.info('定时备份调度已启动(每 10 分钟检查一次到点任务)');
}
/* ================================================================ 备份 / 恢复 */
export interface BackupMeta {
name: string;
kind: 'manual' | 'auto';
sizeBytes: number;
createdAt: string;
}
function isBackupName(name: string): boolean {
return /^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/.test(name);
}
function backupPath(name: string): string {
if (!isBackupName(name)) {
throw ApiError.badRequest('备份文件名不合法');
}
const target = path.join(backupDir(), name);
if (path.dirname(target) !== backupDir()) {
throw ApiError.badRequest('备份文件名不合法');
}
return target;
}
function timestampSlug(): string {
const now = new Date();
const pad = (value: number): string => String(value).padStart(2, '0');
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
);
}
/** 创建一份备份(gzip 压缩的 JSON 快照)。kind 决定文件名前缀与自动清理策略。 */
export async function createBackup(kind: 'manual' | 'auto'): Promise<BackupMeta> {
assertNotSwitching();
const dir = ensureBackupDir();
const snapshot = await exportSnapshot(prisma, env.databaseProvider);
const payload = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot), 'utf8'));
const name = `backup-${kind}-${timestampSlug()}.json.gz`;
fs.writeFileSync(path.join(dir, name), payload);
if (kind === 'auto') pruneAutoBackups();
const meta: BackupMeta = { name, kind, sizeBytes: payload.length, createdAt: snapshot.createdAt };
logger.info(`已创建${kind === 'auto' ? '定时' : '手动'}备份:${name}`);
return meta;
}
/** 自动备份按 keepCount 清理最旧的(手动备份不受影响) */
function pruneAutoBackups(): void {
const schedule = readSchedule();
const autos = listBackups()
.filter((item) => item.kind === 'auto')
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
for (const stale of autos.slice(0, Math.max(0, autos.length - schedule.keepCount))) {
try {
fs.unlinkSync(path.join(backupDir(), stale.name));
} catch {
// 清理失败不影响本次备份
}
}
}
export function listBackups(): BackupMeta[] {
const dir = backupDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter(isBackupName)
.map((name) => {
const stat = fs.statSync(path.join(dir, name));
return {
name,
kind: name.includes('-manual-') ? ('manual' as const) : ('auto' as const),
sizeBytes: stat.size,
createdAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
function readBackupSnapshot(name: string): DatabaseSnapshot {
const file = backupPath(name);
if (!fs.existsSync(file)) {
throw ApiError.notFound('备份不存在(可能已被删除)');
}
const snapshot = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')) as DatabaseSnapshot;
assertValidSnapshot(snapshot);
return snapshot;
}
/** 从备份恢复(整库覆盖:先清空现有数据再写入备份内容) */
export async function restoreBackup(name: string): Promise<{ counts: Record<string, number> }> {
assertNotSwitching();
const snapshot = readBackupSnapshot(name);
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
}
export function deleteBackup(name: string): void {
const file = backupPath(name);
if (!fs.existsSync(file)) {
throw ApiError.notFound('备份不存在(可能已被删除)');
}
fs.unlinkSync(file);
}
/* ================================================================ 导入 / 导出 */
/** 导出当前库的快照 JSON(未压缩,供下载与跨库迁移使用) */
export async function exportSnapshotJson(): Promise<string> {
assertNotSwitching();
return JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
}
/** 从 base64 快照导入(整库覆盖)。用于"快捷导入"。 */
export async function importSnapshotBase64(data: string): Promise<{ counts: Record<string, number> }> {
assertNotSwitching();
let snapshot: DatabaseSnapshot;
try {
const json = Buffer.from(data, 'base64').toString('utf8');
snapshot = JSON.parse(json) as DatabaseSnapshot;
} catch {
throw ApiError.badRequest('快照内容不是合法的 base64 JSON');
}
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
}
/** SQLite 数据文件下载(仅 SQLite 模式提供;MySQL 无"单文件"概念) */
export function sqliteFilePathForDownload(): string {
if (env.databaseProvider !== 'sqlite' || !env.sqliteFilePath || !fs.existsSync(env.sqliteFilePath)) {
throw ApiError.badRequest('当前不是 SQLite 数据库或数据文件不存在');
}
return env.sqliteFilePath;
}
/* ================================================================ 状态 / 连接测试 */
export interface TableCount {
name: string;
count: number;
}
async function databaseVersion(provider: DatabaseProvider, client: typeof prisma): Promise<string> {
if (provider === 'mysql') {
const rows = (await client.$queryRawUnsafe('SELECT VERSION() AS version')) as { version?: string }[];
return rows[0]?.version ?? 'unknown';
}
const rows = (await client.$queryRawUnsafe('SELECT sqlite_version() AS version')) as {
version?: string;
}[];
return rows[0]?.version ?? 'unknown';
}
async function databaseSizeBytes(provider: DatabaseProvider, client: typeof prisma): Promise<number | null> {
if (provider === 'sqlite') {
return env.sqliteFilePath && fs.existsSync(env.sqliteFilePath) ? fs.statSync(env.sqliteFilePath).size : 0;
}
const rows = (await client.$queryRawUnsafe(
'SELECT COALESCE(SUM(data_length + index_length), 0) AS size FROM information_schema.tables WHERE table_schema = DATABASE()',
)) as { size?: bigint | number }[];
const size = rows[0]?.size;
return size === undefined ? null : Number(size);
}
export interface DatabaseStatus {
provider: DatabaseProvider;
connected: boolean;
latencyMs: number | null;
version: string | null;
sizeBytes: number | null;
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
databaseUrlMasked: string;
/** SQLite 数据文件绝对路径(MySQL 为空) */
sqliteFilePath: string;
dataDir: string;
backupDir: string;
tables: TableCount[];
backups: ReturnType<typeof listBackups>;
schedule: BackupSchedule;
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
switchRunning: boolean;
}
/** 连接串脱敏:mysql://user:password@host → mysql://user:***@host */
function maskDatabaseUrl(provider: DatabaseProvider, url: string): string {
if (provider !== 'mysql') return url;
return url.replace(/(\/\/[^:/@]+:)[^@]*(@)/, '$1***$2');
}
export async function getDatabaseStatus(): Promise<DatabaseStatus> {
const startedAt = Date.now();
let connected = false;
let version: string | null = null;
try {
version = await databaseVersion(env.databaseProvider, prisma);
connected = true;
} catch {
connected = false;
}
const latencyMs = connected ? Date.now() - startedAt : null;
const delegates = prisma as unknown as Record<
(typeof SNAPSHOT_TABLES)[number],
{ count(): Promise<number> }
>;
const tables: TableCount[] = connected
? await Promise.all(
SNAPSHOT_TABLES.map(async (name): Promise<TableCount> => {
try {
return { name, count: await delegates[name].count() };
} catch {
return { name, count: -1 };
}
}),
)
: [];
return {
provider: env.databaseProvider,
connected,
latencyMs,
version,
sizeBytes: connected ? await databaseSizeBytes(env.databaseProvider, prisma).catch(() => null) : null,
databaseUrlMasked: maskDatabaseUrl(env.databaseProvider, env.databaseUrl),
sqliteFilePath: env.sqliteFilePath,
dataDir: dataDir(),
backupDir: backupDir(),
tables,
backups: listBackups(),
schedule: readSchedule(),
switchRunning: isSwitchRunning(),
};
}
export interface ConnectionTestResult {
ok: boolean;
provider: DatabaseProvider;
latencyMs: number | null;
version: string | null;
/** 新建 SQLite 库时目标文件还不存在,属于正常情况(切换时会自动建库) */
note?: string;
error?: string;
}
/** 测试任意目标库的连通性(发起切换前的第一步) */
export async function testConnection(target: TargetConnectionInput): Promise<ConnectionTestResult> {
const startedAt = Date.now();
let note: string | undefined;
let url = target.url.trim();
if (target.provider === 'sqlite') {
// 与 env.resolveSqliteUrl 同口径:相对路径以 serverRoot 为基准解析为绝对路径
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
if (!fs.existsSync(absolute)) {
const parent = path.dirname(absolute);
if (!fs.existsSync(parent)) {
return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` };
}
note = '目标文件不存在,属新库(切换时会自动建表)';
}
url = `file:${absolute.replace(/\\/g, '/')}`;
}
let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null;
try {
client = await createPrismaClient(target.provider, url);
const version = await databaseVersion(target.provider, client);
return { ok: true, provider: target.provider, latencyMs: Date.now() - startedAt, version, note };
} catch (error) {
return {
ok: false,
provider: target.provider,
latencyMs: null,
version: null,
error: error instanceof Error ? error.message : String(error),
};
} finally {
await client?.$disconnect().catch(() => undefined);
}
}
/* ================================================================ 一键切换 */
export interface SwitchStep {
name: string;
status: 'running' | 'done' | 'error';
detail?: string;
}
export interface SwitchJob {
id: string;
status: 'running' | 'done' | 'error';
target: { provider: DatabaseProvider; url: string };
steps: SwitchStep[];
error?: string;
/** 完成后必填:需要重启服务端才能让新数据库生效 */
restartRequired: boolean;
counts?: Record<string, number>;
backupName?: string;
startedAt: string;
finishedAt?: string;
}
const switchJobs = new Map<string, SwitchJob>();
function isSwitchRunning(): boolean {
for (const job of switchJobs.values()) {
if (job.status === 'running') return true;
}
return false;
}
function assertNotSwitching(): void {
if (isSwitchRunning()) {
throw ApiError.conflict('正在执行数据库切换,请等待完成后再进行备份/导入/恢复');
}
}
/** 防止路径穿越:任务 id 只由本服务生成(时间戳 + 随机数) */
function newJobId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export function getSwitchJob(id: string): SwitchJob | null {
return switchJobs.get(id) ?? null;
}
function stepOf(job: SwitchJob, name: string): SwitchStep {
let step = job.steps.find((item) => item.name === name);
if (!step) {
step = { name, status: 'running' };
job.steps.push(step);
}
return step;
}
function finishStep(job: SwitchJob, name: string, detail?: string): void {
const step = stepOf(job, name);
step.status = 'done';
if (detail) step.detail = detail;
}
function appendDetail(job: SwitchJob, name: string, detail: string): void {
const step = stepOf(job, name);
step.detail = `${step.detail ? `${step.detail}\n` : ''}${detail}`.slice(-4000);
}
/** 校验目标库必须是空库(防误覆盖):有表即拒绝 */
async function assertTargetEmpty(target: TargetConnectionInput): Promise<string> {
let url = target.url.trim();
let sqliteAbsolute: string | null = null;
if (target.provider === 'sqlite') {
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
sqliteAbsolute = absolute;
url = `file:${absolute.replace(/\\/g, '/')}`;
}
const client = await createPrismaClient(target.provider, url);
try {
const rows =
target.provider === 'sqlite'
? (await client.$queryRawUnsafe(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
)) as { count?: bigint | number }[]
: (await client.$queryRawUnsafe(
'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()',
)) as { count?: bigint | number }[];
const tableCount = Number(rows[0]?.count ?? 0);
if (tableCount > 0) {
throw ApiError.badRequest(
`目标数据库不是空库(已有 ${tableCount} 张表)。为防误覆盖,只允许迁移到空库;` +
'如需重用已有库,请先备份并清空它。',
);
}
return target.provider === 'sqlite' ? `目标文件:${sqliteAbsolute}` : '目标 MySQL 库为空';
} finally {
await client.$disconnect().catch(() => undefined);
}
}
const SCHEMA_FILE = path.join(env.serverRoot, 'prisma', 'schema.prisma');
function rewriteSchemaProvider(provider: DatabaseProvider): string {
const original = fs.readFileSync(SCHEMA_FILE, 'utf8');
const updated = original.replace(/provider\s*=\s*"(sqlite|mysql)"/, `provider = "${provider}"`);
if (updated === original) {
throw new Error('未能改写 schema.prisma 的 provider(未匹配到 provider 声明)');
}
fs.writeFileSync(SCHEMA_FILE, updated, 'utf8');
return original;
}
/** 子进程运行 Prisma CLI(generate / db push)。连接串通过环境变量显式传入,不依赖 .env。 */
function runPrismaCli(
job: SwitchJob,
stepName: string,
args: string[],
target: TargetConnectionInput,
): Promise<void> {
const candidates = [
path.join(env.serverRoot, 'node_modules', 'prisma', 'build', 'index.js'),
path.join(env.serverRoot, '..', 'node_modules', 'prisma', 'build', 'index.js'),
];
const cli = candidates.find((file) => fs.existsSync(file));
if (!cli) {
throw new Error('未找到 Prisma CLI(node_modules/prisma)。安装版请确认安装包完整。');
}
return runNodeTool(job, stepName, [cli, ...args], {
...process.env,
DATABASE_PROVIDER: target.provider,
DATABASE_URL: target.url.trim(),
});
}
/** 编译新生成的 Prisma 客户端(仅生产打包形态需要:dist 里跑的是编译产物) */
async function compileGeneratedClient(job: SwitchJob): Promise<void> {
// 开发模式(tsx 直接跑 src)不需要编译;打包形态(存在 dist/index.js)才需要
if (!fs.existsSync(path.join(env.serverRoot, 'dist', 'index.js'))) return;
const candidates = [
path.join(env.serverRoot, 'node_modules', 'typescript', 'lib', 'tsc.js'),
path.join(env.serverRoot, '..', 'node_modules', 'typescript', 'lib', 'tsc.js'),
];
const tsc = candidates.find((file) => fs.existsSync(file));
if (!tsc) {
throw new Error('未找到 TypeScript 编译器(node_modules/typescript)。安装包请确认完整。');
}
await runNodeTool(job, '编译新生成的 Prisma 客户端', [tsc, '-p', 'tsconfig.generate.json'], process.env);
}
/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */
function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise<Record<string, number>> {
const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js');
const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts');
const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
let args: string[];
if (fs.existsSync(distTool)) {
args = [distTool];
} else if (fs.existsSync(tsxCli) && fs.existsSync(srcTool)) {
args = [tsxCli, srcTool];
} else {
throw new Error('未找到快照导入工具(dist/tools/apply-snapshot.js),请先构建服务端');
}
args.push('--provider', target.provider, '--url', target.url.trim(), '--file', snapshotFile);
const capture: { text: string } = { text: '' };
return runNodeTool(job, '迁移数据到目标库', args, process.env, capture).then(() => {
const lines = capture.text.trim().split(/\r?\n/).filter(Boolean);
const last = lines[lines.length - 1] ?? '';
try {
const parsed = JSON.parse(last) as { ok?: boolean; counts?: Record<string, number>; error?: string };
if (!parsed.ok) throw new Error(parsed.error ?? '快照导入工具返回失败');
return parsed.counts ?? {};
} catch (error) {
throw new Error(
`无法解析快照导入工具的输出:${error instanceof Error ? error.message : String(error)}(输出尾部:${last.slice(0, 400)})`,
);
}
});
}
function runNodeTool(
job: SwitchJob,
stepName: string,
args: string[],
nodeEnv: NodeJS.ProcessEnv,
capture?: { text: string },
timeoutMs = 10 * 60_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
cwd: env.serverRoot,
env: nodeEnv,
windowsHide: true,
});
let output = '';
const collect = (chunk: Buffer | string): void => {
output += String(chunk);
if (output.length > 20_000) output = output.slice(-20_000);
if (capture) capture.text = output;
};
child.stdout?.on('data', collect);
child.stderr?.on('data', collect);
const timer = setTimeout(() => {
child.kill();
reject(new Error(`${stepName} 超时(${Math.round(timeoutMs / 60_000)} 分钟)`));
}, timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
reject(new Error(`${stepName} 启动失败:${error.message}`));
});
child.on('exit', (code) => {
clearTimeout(timer);
if (code === 0) {
appendDetail(job, stepName, output.trim().split(/\r?\n/).slice(-6).join('\n'));
resolve();
return;
}
const tail = output.trim().split(/\r?\n/).slice(-10).join('\n');
reject(new Error(`${stepName} 失败(exit ${code ?? 'signal'}):${tail || '无输出'}`));
});
});
}
/** 切换完成后改写 .env(DATABASE_PROVIDER / DATABASE_URL 两行) */
function updateEnvFiles(target: TargetConnectionInput): string {
const candidates = [path.join(env.serverRoot, '.env'), path.join(env.serverRoot, '..', '.env')];
const existing = candidates.filter((file) => fs.existsSync(file));
const files = existing.length > 0 ? existing : [candidates[0]];
const updated: string[] = [];
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
const providerLine = `DATABASE_PROVIDER=${target.provider}`;
const urlLine = `DATABASE_URL="${target.url.trim()}"`;
if (/^DATABASE_PROVIDER=.*$/m.test(content)) {
content = content.replace(/^DATABASE_PROVIDER=.*$/m, providerLine);
} else {
content = `${content.trimEnd()}\n${providerLine}\n`;
}
if (/^DATABASE_URL=.*$/m.test(content)) {
content = content.replace(/^DATABASE_URL=.*$/m, urlLine);
} else {
content = `${content.trimEnd()}\n${urlLine}\n`;
}
fs.writeFileSync(file, content, 'utf8');
updated.push(file);
}
return updated.join(' , ');
}
/**
* 发起一键切换(异步任务)。流程与失败语义见模块注释:
* 数据写入全部成功后才改写 .env;中途失败回滚 schema.prisma,现有库不受影响。
*/
export function startSwitch(target: TargetConnectionInput): SwitchJob {
if (isSwitchRunning()) {
throw ApiError.conflict('已有切换任务正在进行,请等待完成');
}
if (target.provider === env.databaseProvider && target.url.trim() === env.databaseUrl) {
throw ApiError.badRequest('目标数据库与当前使用的数据库相同');
}
const job: SwitchJob = {
id: newJobId(),
status: 'running',
target: { provider: target.provider, url: target.url.trim() },
steps: [],
restartRequired: false,
startedAt: new Date().toISOString(),
};
switchJobs.set(job.id, job);
// 不 await:HTTP 立即返回 jobId,前端轮询 GET /switch/jobs/:id
void runSwitch(job, target).catch(() => undefined);
return job;
}
async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise<void> {
let schemaOriginal: string | null = null;
let snapshotFile: string | null = null;
try {
// 1. 先备份当前库(切换失败时的兜底)
stepOf(job, '备份当前数据库');
const backup = await createBackup('manual');
job.backupName = backup.name;
finishStep(job, '备份当前数据库', `备份文件 ${backup.name}`);
// 2. 导出快照到临时文件(供子进程写入目标库)
stepOf(job, '导出当前数据');
fs.mkdirSync(backupDir(), { recursive: true });
snapshotFile = path.join(backupDir(), `switch-${job.id}.json`);
fs.writeFileSync(snapshotFile, await exportSnapshotJson(), 'utf8');
finishStep(job, '导出当前数据');
// 3. 测试目标连接
stepOf(job, '测试目标库连接');
const test = await testConnection(target);
if (!test.ok) {
throw new Error(test.error ?? '目标库连接失败');
}
finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`);
// 4. 目标库必须为空
stepOf(job, '检查目标库为空');
finishStep(job, '检查目标库为空', await assertTargetEmpty(target));
// 5. 改写 schema.prisma 的 provider(失败时回滚)
stepOf(job, '改写 schema.prisma');
schemaOriginal = rewriteSchemaProvider(target.provider);
finishStep(job, '改写 schema.prisma', `provider → ${target.provider}`);
// 6. 生成新方言的 Prisma 客户端
stepOf(job, '生成 Prisma 客户端');
await runPrismaCli(job, '生成 Prisma 客户端', ['generate'], target);
finishStep(job, '生成 Prisma 客户端');
// 7. 生产形态:把生成出来的 .ts 客户端编译进 dist(dev 由 tsx 直接跑 src,跳过)
await compileGeneratedClient(job);
// 8. 目标库建表(Prisma 7 的 db push 不再自动生成客户端,generate 是独立步骤 6)
stepOf(job, '在目标库建表');
await runPrismaCli(job, '在目标库建表', ['db', 'push', '--accept-data-loss'], target);
finishStep(job, '在目标库建表');
// 9. 迁移数据(独立子进程加载新方言客户端)
stepOf(job, '迁移数据到目标库');
const counts = await runApplySnapshotTool(job, target, snapshotFile);
job.counts = counts;
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
finishStep(job, '迁移数据到目标库', `共写入 ${total} 行`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// 失败发生在哪一步,就把那一步标成 error(UI 上能直接看到卡在哪)
const running = [...job.steps].reverse().find((item) => item.status === 'running');
if (running) {
running.status = 'error';
running.detail = message.slice(0, 1000);
}
job.error = message;
job.status = 'error';
job.finishedAt = new Date().toISOString();
// 回滚 schema(.env 尚未改写,现有库不受影响)
if (schemaOriginal !== null) {
try {
fs.writeFileSync(SCHEMA_FILE, schemaOriginal, 'utf8');
} catch (rollbackError) {
logger.error('回滚 schema.prisma 失败,请手工核对 provider 设置', rollbackError);
}
}
logger.error(`数据库切换失败(job=${job.id}):${message}`);
return;
} finally {
if (snapshotFile) fs.rmSync(snapshotFile, { force: true });
}
// 10. 全部成功后才切换 .env(失败已在上面的 catch 里返回)
try {
stepOf(job, '写入 .env 配置');
const files = updateEnvFiles(target);
finishStep(job, '写入 .env 配置', files);
job.status = 'done';
job.restartRequired = true;
job.finishedAt = new Date().toISOString();
logger.info(`数据库切换任务完成(job=${job.id})→ ${target.provider},重启服务端后生效`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
job.error = `数据已迁移到目标库,但写入 .env 失败:${message}(请手工修改 .env 的 DATABASE_PROVIDER / DATABASE_URL 后重启)`;
job.status = 'error';
job.finishedAt = new Date().toISOString();
}
}
+2
View File
@@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js';
@@ -34,4 +35,5 @@ export const apiModules: ApiModule[] = [
studentsModule,
teachersModule,
dashboardModule,
databaseModule,
].filter((module) => module.enabled !== false);
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import process from 'node:process';
import { createPrismaClient, type DatabaseProvider } from '../lib/db.js';
import { assertValidSnapshot, restoreSnapshot, type DatabaseSnapshot } from '../lib/snapshot.js';
/**
* 快照导入工具(供跨库迁移的**子进程**调用,也可手工使用)。
*
* 为什么是子进程:切换数据库时 `prisma generate` 会重新生成 Prisma Client(SQL 方言变了),
* 而正在运行的服务进程里缓存的是**旧方言**的客户端模块 —— 只有新进程才能加载重新生成的客户端,
* 把快照写进新库。用法:
*
* node dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json
*
* stdout 最后一行输出 JSON:{ ok, counts },父进程据此回填任务进度;失败时 exit 1 并输出原因。
* 注意 --file 是**未压缩**的 JSON(父进程负责解压到临时文件)。
*/
interface ToolArgs {
provider: DatabaseProvider;
url: string;
file: string;
}
function parseArgs(argv: readonly string[]): ToolArgs {
const read = (name: string): string => {
const index = argv.indexOf(`--${name}`);
const value = index >= 0 ? argv[index + 1] : undefined;
if (!value) throw new Error(`缺少参数 --${name}`);
return value;
};
const provider = read('provider');
if (provider !== 'sqlite' && provider !== 'mysql') {
throw new Error(`不支持的 provider:${provider}(只支持 sqlite / mysql)`);
}
return { provider, url: read('url'), file: read('file') };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const raw = fs.readFileSync(args.file, 'utf8');
const snapshot = JSON.parse(raw) as DatabaseSnapshot;
assertValidSnapshot(snapshot);
const client = await createPrismaClient(args.provider, args.url);
try {
const { counts } = await restoreSnapshot(client, args.provider, snapshot);
process.stdout.write(`${JSON.stringify({ ok: true, counts })}\n`);
} finally {
await client.$disconnect().catch(() => undefined);
}
}
main().catch((error: unknown) => {
process.stdout.write(
`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`,
);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/generated/**/*.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module",
+8
View File
@@ -25,6 +25,8 @@ export const API_PATHS = {
imports: '/imports',
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
integrations: '/integrations',
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
database: '/database',
dashboard: '/dashboard/summary',
} as const;
@@ -476,6 +478,12 @@ export const STORAGE_KEYS = {
serverUrl: 'classhelper.serverUrl',
cachePrefix: 'classhelper.cache',
lastSyncAt: 'classhelper.lastSyncAt',
/**
* 新手引导「已看过」标记(Web 管理端 localStorage)。
* 值为引导版本号:改版引导内容后版本 +1,看过旧版的老用户会再收到一次新版引导。
* EXE 客户端不用这个键 —— 它的引导状态在主进程配置(`onboardingDone`)里。
*/
onboarding: 'classhelper.onboarding',
} as const;
/** 桌面客户端默认后端地址 */
+81
View File
@@ -1017,3 +1017,84 @@ export interface SocketAuthPayload {
token: string;
clientType?: 'admin' | 'desktop';
}
/* ================================================================ 数据库管理(仅管理员) */
/** 数据库管理支持的提供方(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
export type DatabaseProvider = 'sqlite' | 'mysql';
/** GET /api/database/status 的返回 */
export interface DatabaseStatusDto {
provider: DatabaseProvider;
connected: boolean;
/** ping 延迟(毫秒);未连接时为 null */
latencyMs: number | null;
/** 数据库引擎版本(sqlite_version() / VERSION()) */
version: string | null;
/** 数据体积(字节);MySQL 取 information_schema 汇总 */
sizeBytes: number | null;
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
databaseUrlMasked: string;
/** SQLite 数据文件绝对路径(MySQL 为空串) */
sqliteFilePath: string;
/** 数据目录(备份与定时配置所在处) */
dataDir: string;
backupDir: string;
tables: DatabaseTableCountDto[];
backups: DatabaseBackupMetaDto[];
schedule: DatabaseBackupScheduleDto;
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
switchRunning: boolean;
}
export interface DatabaseTableCountDto {
name: string;
/** -1 表示统计失败(表结构与代码不一致时出现) */
count: number;
}
export interface DatabaseBackupMetaDto {
name: string;
kind: 'manual' | 'auto';
sizeBytes: number;
createdAt: string;
}
export interface DatabaseBackupScheduleDto {
enabled: boolean;
intervalHours: number;
keepCount: number;
lastAutoBackupAt: string | null;
}
/** POST /api/database/test-connection 的返回 */
export interface DatabaseConnectionTestDto {
ok: boolean;
provider: DatabaseProvider;
latencyMs: number | null;
version: string | null;
/** 新建 SQLite 库时目标文件还不存在,属正常情况(切换时会自动建库) */
note?: string;
error?: string;
}
/** 数据库切换任务(POST /switch 后用 jobId 轮询) */
export interface DatabaseSwitchJobDto {
id: string;
status: 'running' | 'done' | 'error';
target: { provider: DatabaseProvider; url: string };
steps: DatabaseSwitchStepDto[];
error?: string;
/** 完成后为 true:需要重启服务端才能让新数据库生效 */
restartRequired: boolean;
counts?: Record<string, number>;
backupName?: string;
startedAt: string;
finishedAt?: string;
}
export interface DatabaseSwitchStepDto {
name: string;
status: 'running' | 'done' | 'error';
detail?: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
+5
View File
@@ -62,4 +62,9 @@ export const api = {
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
put: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PUT', url, data }),
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
/**
* 下载二进制(返回 Blob,调用方负责触发浏览器保存)。
* 请求拦截器照常带 token;响应是 Blob 时拦截器原样放行(不含 success 字段)。
*/
download: (url: string): Promise<Blob> => request<Blob>({ method: 'GET', url, responseType: 'blob' }),
};
+38
View File
@@ -15,6 +15,11 @@ import {
type CreateNotificationRequest,
type CreateScheduleRequest,
type DashboardSummary,
type DatabaseBackupMetaDto,
type DatabaseBackupScheduleDto,
type DatabaseConnectionTestDto,
type DatabaseStatusDto,
type DatabaseSwitchJobDto,
type GradeDto,
type GradeStats,
type HomeworkDaysDto,
@@ -328,3 +333,36 @@ export const integrationApi = {
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
};
/* ------------------------------------------------------------------ 数据库管理(仅管理员) */
/**
* 数据库管理:状态 / 连接测试 / 备份 / 导入导出 / 定时备份 / 一键切换。
*
* 「快照下载」与「数据库文件下载」是二进制流,走 http.ts 的 download()(返回 Blob),
* 由页面负责触发浏览器保存;导入把快照 JSON 转 base64 放进请求体(受 12MB 请求体限制)。
*/
export const databaseApi = {
status: (): Promise<DatabaseStatusDto> => api.get(`${API_PATHS.database}/status`),
testConnection: (payload: { provider: 'sqlite' | 'mysql'; url: string }): Promise<DatabaseConnectionTestDto> =>
api.post(`${API_PATHS.database}/test-connection`, payload),
listBackups: (): Promise<DatabaseBackupMetaDto[]> => api.get(`${API_PATHS.database}/backups`),
createBackup: (): Promise<DatabaseBackupMetaDto> => api.post(`${API_PATHS.database}/backups`, {}),
restoreBackup: (name: string): Promise<{ counts: Record<string, number> }> =>
api.post(`${API_PATHS.database}/backups/${encodeURIComponent(name)}/restore`, {}),
deleteBackup: (name: string): Promise<{ name: string }> =>
api.delete(`${API_PATHS.database}/backups/${encodeURIComponent(name)}`),
importSnapshot: (base64: string): Promise<{ counts: Record<string, number> }> =>
api.post(`${API_PATHS.database}/import`, { data: base64 }),
/** 下载当前库的 JSON 快照(Blob,页面触发浏览器保存) */
downloadSnapshot: (): Promise<Blob> => api.download(`${API_PATHS.database}/export`),
/** 下载数据库文件(仅 SQLite 模式;MySQL 无单文件概念) */
downloadSqliteFile: (): Promise<Blob> => api.download(`${API_PATHS.database}/sqlite-file`),
getSchedule: (): Promise<DatabaseBackupScheduleDto> => api.get(`${API_PATHS.database}/backup-schedule`),
saveSchedule: (payload: DatabaseBackupScheduleDto): Promise<DatabaseBackupScheduleDto> =>
api.put(`${API_PATHS.database}/backup-schedule`, payload),
startSwitch: (payload: { provider: 'sqlite' | 'mysql'; url: string }): Promise<{ jobId: string }> =>
api.post(`${API_PATHS.database}/switch`, payload),
getSwitchJob: (jobId: string): Promise<DatabaseSwitchJobDto | null> =>
api.get(`${API_PATHS.database}/switch/jobs/${encodeURIComponent(jobId)}`),
};
+105 -4
View File
@@ -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>
+7
View File
@@ -68,6 +68,13 @@ const routes: RouteRecordRaw[] = [
// 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页
meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
},
{
path: 'database',
name: 'database',
component: () => import('@/views/DatabaseView.vue'),
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
},
],
},
{
@@ -0,0 +1,582 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox, type UploadFile, type UploadInstance } from 'element-plus';
import type { DatabaseBackupScheduleDto, DatabaseStatusDto, DatabaseSwitchJobDto } from '@classhelper/shared';
import { databaseApi } from '@/api';
import { useResponsive } from '@/composables/useResponsive';
/**
* 数据库管理(仅管理员):
* - 状态卡:当前连接 / 版本 / 体积 / 各表行数
* - 连接测试卡:任意目标库连通性(切换前的前置检查)
* - 一键切换卡:SQLite ⇄ MySQL 迁移(异步任务轮询;完成后需重启服务端生效)
* - 备份卡:手动/定时备份、恢复、删除、快照导出导入、数据库文件下载
*
* 恢复 / 导入 / 切换都是**整库覆盖**级危险操作,全部走 ElMessageBox 二次确认,
* 切换还要求用户手输「确认迁移」防止误触。
*/
const { isMobile } = useResponsive();
const loading = ref(false);
const status = ref<DatabaseStatusDto | null>(null);
const isSqlite = computed(() => status.value?.provider === 'sqlite');
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatTime(value: string | null | undefined): string {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
async function reload(): Promise<void> {
loading.value = true;
try {
status.value = await databaseApi.status();
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 连接测试 */
const testForm = reactive({ provider: 'mysql' as 'sqlite' | 'mysql', url: '' });
const testing = ref(false);
const testResult = ref<{ ok: boolean; text: string } | null>(null);
async function runTest(): Promise<void> {
if (!testForm.url.trim()) {
ElMessage.warning('请先填写目标连接串');
return;
}
testing.value = true;
testResult.value = null;
try {
const result = await databaseApi.testConnection({ provider: testForm.provider, url: testForm.url.trim() });
testResult.value = {
ok: result.ok,
text: result.ok
? `连接成功(${result.latencyMs ?? '-'}ms,版本 ${result.version ?? '-'})${result.note ?? ''}`
: `连接失败:${result.error ?? '未知原因'}`,
};
} finally {
testing.value = false;
}
}
/* ------------------------------------------------------------ 一键切换 */
const switchForm = reactive({ provider: 'mysql' as 'sqlite' | 'mysql', url: '', confirmText: '' });
const switchJob = ref<DatabaseSwitchJobDto | null>(null);
const switchPolling = ref(false);
let switchTimer: number | null = null;
/** 切换目标默认按「与当前相反」的 provider 预填 */
watch(
() => status.value?.provider,
(provider) => {
if (provider === 'sqlite') switchForm.provider = 'mysql';
if (provider === 'mysql') switchForm.provider = 'sqlite';
},
{ immediate: true },
);
async function startSwitch(): Promise<void> {
if (!status.value) return;
if (switchForm.confirmText !== '确认迁移') {
ElMessage.warning('请先在输入框中输入「确认迁移」');
return;
}
const confirmed = await ElMessageBox.confirm(
`将把当前${status.value.provider === 'sqlite' ? 'SQLite' : 'MySQL'}数据库的全部数据迁移到目标库,` +
'目标库必须为空;完成后需要重启服务端才能生效。确定继续?',
'一键切换数据库',
{ type: 'warning', confirmButtonText: '开始迁移', cancelButtonText: '取消' },
).catch(() => false);
if (!confirmed) return;
const created = await databaseApi.startSwitch({
provider: switchForm.provider,
url: switchForm.url.trim(),
});
switchForm.confirmText = '';
switchJob.value = null;
switchPolling.value = true;
const poll = async (): Promise<void> => {
const job = await databaseApi.getSwitchJob(created.jobId);
switchJob.value = job;
if (job && job.status !== 'running') {
switchPolling.value = false;
if (switchTimer) {
window.clearTimeout(switchTimer);
switchTimer = null;
}
await reload();
if (job.status === 'done') ElMessage.success('迁移完成,请重启服务端使新数据库生效');
else ElMessage.error(job.error ?? '迁移失败(已回滚,当前数据库未受影响)');
return;
}
switchTimer = window.setTimeout(() => void poll(), 2000);
};
await poll();
}
onUnmounted(() => {
if (switchTimer) window.clearTimeout(switchTimer);
});
/** el-step 的 status 取值:wait/process/finish/error/success */
function switchStepStatus(stepStatus: 'running' | 'done' | 'error'): 'process' | 'success' | 'error' {
return stepStatus === 'done' ? 'success' : stepStatus === 'error' ? 'error' : 'process';
}
/* ------------------------------------------------------------ 备份 / 恢复 */
const backups = computed(() => status.value?.backups ?? []);
async function createBackup(): Promise<void> {
await databaseApi.createBackup();
ElMessage.success('备份已创建');
await reload();
}
async function restoreBackup(name: string): Promise<void> {
const confirmed = await ElMessageBox.confirm(
'恢复会用备份内容覆盖当前数据库的全部数据(恢复点之后新增的数据将丢失)。确定继续?',
'从备份恢复',
{ type: 'warning', confirmButtonText: '恢复', cancelButtonText: '取消' },
).catch(() => false);
if (!confirmed) return;
await databaseApi.restoreBackup(name);
ElMessage.success('已从备份恢复');
await reload();
}
async function deleteBackup(name: string): Promise<void> {
const confirmed = await ElMessageBox.confirm(`确定删除备份 ${name}?`, '删除备份', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
}).catch(() => false);
if (!confirmed) return;
await databaseApi.deleteBackup(name);
ElMessage.success('备份已删除');
await reload();
}
/* ------------------------------------------------------------ 导出 / 导入 */
function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
async function exportSnapshot(): Promise<void> {
const blob = await databaseApi.downloadSnapshot();
saveBlob(blob, `classhelper-snapshot-${new Date().toISOString().slice(0, 10)}.json`);
}
async function exportSqliteFile(): Promise<void> {
const blob = await databaseApi.downloadSqliteFile();
saveBlob(blob, 'classhelper.db');
}
const importUploadRef = ref<UploadInstance>();
const importing = ref(false);
/** 二进制 → base64(分块转换,避免大文件把 String.fromCharCode 的调用栈撑爆) */
function bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
const step = 0x8000;
for (let index = 0; index < bytes.length; index += step) {
chunks.push(String.fromCharCode(...bytes.subarray(index, index + step)));
}
return btoa(chunks.join(''));
}
/** 读文件为 base64 后整库导入(覆盖现有数据) */
async function handleImportFile(file: File): Promise<void> {
const confirmed = await ElMessageBox.confirm(
'导入会用快照内容覆盖当前数据库的全部数据。确定继续?',
'导入快照',
{ type: 'warning', confirmButtonText: '导入', cancelButtonText: '取消' },
).catch(() => false);
if (!confirmed) return;
importing.value = true;
try {
await databaseApi.importSnapshot(bufferToBase64(await file.arrayBuffer()));
ElMessage.success('快照已导入');
await reload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败');
} finally {
importing.value = false;
importUploadRef.value?.clearFiles();
}
}
function onImportChange(uploadFile: UploadFile): void {
if (uploadFile.raw) void handleImportFile(uploadFile.raw);
}
/* ------------------------------------------------------------ 定时备份 */
const scheduleForm = reactive<DatabaseBackupScheduleDto>({
enabled: false,
intervalHours: 24,
keepCount: 7,
lastAutoBackupAt: null,
});
const scheduleSaving = ref(false);
async function saveSchedule(): Promise<void> {
scheduleSaving.value = true;
try {
const saved = await databaseApi.saveSchedule({
enabled: scheduleForm.enabled,
intervalHours: Number(scheduleForm.intervalHours),
keepCount: Number(scheduleForm.keepCount),
lastAutoBackupAt: null,
});
Object.assign(scheduleForm, saved);
ElMessage.success('定时备份配置已保存');
await reload();
} finally {
scheduleSaving.value = false;
}
}
onMounted(async () => {
await reload();
const schedule = status.value?.schedule;
if (schedule) {
Object.assign(scheduleForm, {
enabled: schedule.enabled,
intervalHours: schedule.intervalHours,
keepCount: schedule.keepCount,
lastAutoBackupAt: schedule.lastAutoBackupAt,
});
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">数据库管理</h2>
<p class="page-subtitle">连接状态、备份恢复、导入导出与一键切换(仅管理员可见)</p>
</div>
<div class="toolbar">
<el-button icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
</div>
</div>
<!-- 当前状态 -->
<el-card shadow="never" class="section-card">
<template #header><span>当前数据库</span></template>
<el-descriptions :column="isMobile ? 1 : 2" size="small" border>
<el-descriptions-item label="类型">
<el-tag size="small" :type="isSqlite ? 'success' : 'primary'">
{{ isSqlite ? 'SQLite' : 'MySQL' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="连接状态">
<el-tag size="small" :type="status?.connected ? 'success' : 'danger'">
{{ status?.connected ? `已连接 · ${status.latencyMs ?? '-'}ms` : '未连接' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据库版本">{{ status?.version ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="数据体积">{{ formatBytes(status?.sizeBytes) }}</el-descriptions-item>
<el-descriptions-item label="连接串">{{ status?.databaseUrlMasked ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="备份目录">{{ status?.backupDir ?? '—' }}</el-descriptions-item>
<el-descriptions-item v-if="isSqlite" label="数据文件" :span="isMobile ? 1 : 2">
{{ status?.sqliteFilePath ?? '—' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 各表行数 + 备份列表 -->
<el-row :gutter="12" class="mt-12">
<el-col :xs="24" :md="12">
<el-card shadow="never" class="table-card">
<template #header><span>各表行数</span></template>
<el-table :data="status?.tables ?? []" size="small" max-height="360">
<el-table-column prop="name" label="表" min-width="140" />
<el-table-column label="行数" width="100">
<template #default="{ row }">
<el-tag v-if="row.count >= 0" size="small" type="info" effect="plain">{{ row.count }}</el-tag>
<el-tag v-else size="small" type="danger">异常</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never" class="table-card">
<template #header>
<div class="card-header-row">
<span>备份</span>
<el-button size="small" type="primary" plain @click="createBackup">立即备份</el-button>
</div>
</template>
<el-table :data="backups" size="small" max-height="300" empty-text="暂无备份">
<el-table-column label="文件" min-width="200">
<template #default="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.kind === 'manual' ? 'primary' : 'info'">
{{ row.kind === 'manual' ? '手动' : '定时' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template>
</el-table-column>
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="restoreBackup(row.name)">恢复</el-button>
<el-button link type="danger" size="small" @click="deleteBackup(row.name)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-divider />
<el-form label-width="96px" size="small" class="schedule-form">
<el-form-item label="定时备份">
<el-switch v-model="scheduleForm.enabled" />
</el-form-item>
<el-form-item label="间隔(小时)">
<el-input-number v-model="scheduleForm.intervalHours" :min="1" :max="720" :step="1" />
<span class="form-hint">到点自动备份(服务端运行期间生效)</span>
</el-form-item>
<el-form-item label="保留份数">
<el-input-number v-model="scheduleForm.keepCount" :min="1" :max="100" :step="1" />
<span class="form-hint">超出后自动删最旧的定时备份</span>
</el-form-item>
<el-form-item label="上次备份">
<span class="form-hint">{{ formatTime(scheduleForm.lastAutoBackupAt) }}</span>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" :loading="scheduleSaving" @click="saveSchedule">
保存定时配置
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<!-- 连接测试 / 导入导出 -->
<el-row :gutter="12" class="mt-12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>连接测试(任意目标库)</span></template>
<el-form label-position="top" size="small">
<el-form-item label="目标类型">
<el-radio-group v-model="testForm.provider">
<el-radio-button value="sqlite">SQLite</el-radio-button>
<el-radio-button value="mysql">MySQL</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="连接串">
<el-input
v-model="testForm.url"
:placeholder="
testForm.provider === 'sqlite'
? 'file:./data/target.db'
: 'mysql://user:password@host:3306/classhelper'
"
clearable
/>
</el-form-item>
<el-button type="primary" :loading="testing" @click="runTest">测试连接</el-button>
<el-alert
v-if="testResult"
class="mt-12"
:type="testResult.ok ? 'success' : 'error'"
:closable="false"
:title="testResult.text"
/>
</el-form>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>导入 / 导出</span></template>
<p class="card-hint">
快照为 JSON 格式(含全部 14 张表数据),可在 SQLite 与 MySQL 之间互相恢复;
「数据库文件」仅 SQLite 提供整文件下载。
</p>
<div class="toolbar-row">
<el-button @click="exportSnapshot">下载快照 JSON</el-button>
<el-button v-if="isSqlite" @click="exportSqliteFile">下载数据库文件</el-button>
<el-upload
ref="importUploadRef"
:auto-upload="false"
:show-file-list="false"
:on-change="onImportChange"
accept=".json,application/json"
>
<el-button type="warning" plain :loading="importing">导入快照(覆盖现有数据)</el-button>
</el-upload>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="导入会整库覆盖当前数据"
description="建议先「立即备份」再导入;.json.gz 压缩备份请先解压为 .json 再导入。"
/>
</el-card>
</el-col>
</el-row>
<!-- 一键切换 -->
<el-card shadow="never" class="mt-12 section-card">
<template #header><span>一键切换数据库(SQLite ⇄ MySQL)</span></template>
<el-alert
class="mb-12"
type="warning"
:closable="false"
title="切换会自动:备份当前库 → 建目标库表结构 → 迁移全部数据 → 改写 .env"
description="目标库必须为空;完成后需重启服务端(安装版运行 restart.cmd,开发模式重跑 pnpm dev:server)才会连到新库。中途失败会自动回滚,当前数据库不受影响。"
/>
<el-form label-position="top" size="small" class="switch-form">
<el-row :gutter="12">
<el-col :xs="24" :md="6">
<el-form-item label="目标类型">
<el-radio-group v-model="switchForm.provider">
<el-radio-button value="sqlite">SQLite</el-radio-button>
<el-radio-button value="mysql">MySQL</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="目标连接串(库必须为空)">
<el-input
v-model="switchForm.url"
:placeholder="
switchForm.provider === 'sqlite'
? 'file:./data/classhelper-target.db'
: 'mysql://user:password@host:3306/classhelper'
"
clearable
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="6">
<el-form-item label="输入「确认迁移」启用按钮">
<el-input v-model="switchForm.confirmText" placeholder="确认迁移" clearable />
</el-form-item>
</el-col>
</el-row>
<el-button
type="danger"
:disabled="switchForm.confirmText !== '确认迁移' || switchPolling"
@click="startSwitch"
>
开始迁移
</el-button>
</el-form>
<!-- 任务进度 -->
<template v-if="switchJob">
<el-descriptions class="mt-12" :column="isMobile ? 1 : 2" size="small" border>
<el-descriptions-item label="任务状态">
<el-tag
size="small"
:type="
switchJob.status === 'done' ? 'success' : switchJob.status === 'error' ? 'danger' : 'warning'
"
>
{{ switchJob.status === 'done' ? '已完成' : switchJob.status === 'error' ? '失败' : '进行中' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="目标">
{{ switchJob.target.provider }} · {{ switchJob.target.url }}
</el-descriptions-item>
</el-descriptions>
<el-steps class="mt-12" direction="vertical" :space="44">
<el-step
v-for="step in switchJob.steps"
:key="step.name"
:title="step.name"
:status="switchStepStatus(step.status)"
:description="step.detail"
/>
</el-steps>
<el-alert
v-if="switchJob.status === 'done'"
class="mt-12"
type="success"
:closable="false"
title="迁移完成:请立即重启服务端(restart.cmd 或重跑 pnpm dev:server),重启后刷新本页将显示新数据库"
/>
<el-alert
v-if="switchJob.status === 'error'"
class="mt-12"
type="error"
:closable="false"
:title="`迁移失败:${switchJob.error ?? '未知原因'}`"
description="已自动回滚,当前数据库与 .env 未被修改。"
/>
</template>
</el-card>
</div>
</template>
<style scoped>
.section-card {
margin-bottom: 2px;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-form {
margin-top: 4px;
}
.form-hint {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.card-hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.7;
color: var(--el-text-color-secondary);
}
.toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.switch-form {
margin-top: 2px;
}
</style>