feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0
- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
(备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
.env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
(prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
(/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导
验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。
注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
This commit is contained in:
1 parent
391e2c1591
commit
572295c297
66 files changed
+5131
-1027
No files matched your search
@@ -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' }),
|
||||
};
|
||||
@@ -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)}`),
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user