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
@@ -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>