- 服务端新增 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 条),属时间相关用例,
与本次改动无关。
142 lines
5.0 KiB
Vue
142 lines
5.0 KiB
Vue
<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>
|