feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
1 parent
e51879667a
commit
817af02357
131 files changed
+12995
-538
No files matched your search
@@ -0,0 +1,450 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
|
||||
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useUiStore } from '../../stores/ui.js';
|
||||
|
||||
/**
|
||||
* 开发者(需求 10)。
|
||||
*
|
||||
* 入口只在 `config.developerMode === true` 时出现在侧栏「设置」分组里(`ClientLayout.vue`),
|
||||
* 路由守卫也会把"关掉开关后仍停留在这个页面"的用户弹回「通用」。
|
||||
*
|
||||
* 本页的定位是**把排查问题时需要的现场集中到一处**,所以刻意不做成"可操作的调试台":
|
||||
* 每一项都是只读状态 + 一两个既有桥上的动作,数据全部来自 `window.desktop.*`
|
||||
* 与既有 store,**不新增任何数据源、也不新增文件读写**。
|
||||
*
|
||||
* 几条取舍写在各区块的注释里,统一的原则是:
|
||||
* 「关于」页面向的是所有用户(干净、能截图给管理员),本页面向维护者(原始值、路径、内部开关)。
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
const agent = ref<ClientAgentStatus | null>(null);
|
||||
|
||||
/* ------------------------------------------------------------ 版本与平台 */
|
||||
|
||||
/** 版本、运行时与两个关键路径(用户数据目录 / 配置文件),排查"配置写哪去了"必看 */
|
||||
async function loadAppInfo(): Promise<void> {
|
||||
if (!window.desktop?.getAppInfo) return;
|
||||
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
|
||||
}
|
||||
|
||||
/** 一次性把本页的所有状态拉新(刷新按钮与首次进页面共用) */
|
||||
async function loadAll(): Promise<void> {
|
||||
if (!window.desktop) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadAppInfo();
|
||||
await refreshAgent();
|
||||
await loadIgnoredVersion();
|
||||
await appStore.refreshCacheStats();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 设备与上报 */
|
||||
|
||||
/**
|
||||
* 服务端联动状态:设备尾号 / 最近上报 / 最近错误 / 目标版本 / 待安装版本 / 在线。
|
||||
*
|
||||
* 这一块与「通用」页的「服务端联动状态」是同一份数据 —— 有意保留的重叠:
|
||||
* 排查上报问题时人在「通用」页看现象、在「开发者」页看原因(最近错误原文 + 设备尾号),
|
||||
* 跳来跳去比重复几个字段更烦。
|
||||
*/
|
||||
const beating = ref(false);
|
||||
|
||||
const lastHeartbeatText = computed(() => {
|
||||
const value = agent.value?.lastHeartbeatAt;
|
||||
return value ? relativeTime(value) : '还没有上报过';
|
||||
});
|
||||
|
||||
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
||||
|
||||
async function refreshAgent(): Promise<void> {
|
||||
if (!window.desktop?.getClientAgentStatus) return;
|
||||
agent.value = await window.desktop.getClientAgentStatus().catch(() => null);
|
||||
}
|
||||
|
||||
async function beatNow(): Promise<void> {
|
||||
if (!window.desktop?.beatClientNow) return;
|
||||
beating.value = true;
|
||||
try {
|
||||
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
|
||||
agent.value = await window.desktop.beatClientNow();
|
||||
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
|
||||
else ElMessage.success('已向服务端上报本机状态');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
|
||||
} finally {
|
||||
beating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 日志 */
|
||||
|
||||
/**
|
||||
* 日志文件路径。
|
||||
*
|
||||
* 桥上没有"读日志内容"的接口(有意不加:日志是主进程的文件,给渲染进程一个读任意文件的
|
||||
* 入口在安全边界上没有必要),所以这里只**提示路径** + 一个「打开日志目录」。
|
||||
* 路径由用户数据目录拼出来:主进程的 logger 固定写在 `<userData>/logs/main.log`,
|
||||
* 单文件 1MB 轮转(写满即改名为 main.old.log),所以"日志丢了"通常是轮转覆盖,不是没写。
|
||||
*/
|
||||
const logPathText = computed(() => {
|
||||
const base = appInfo.value?.userDataPath;
|
||||
if (!base) return 'logs/main.log(用户数据目录下)';
|
||||
const sep = (appInfo.value?.platform ?? '').startsWith('win') ? '\\' : '/';
|
||||
return `${base}${sep}logs${sep}main.log`;
|
||||
});
|
||||
|
||||
async function openLogDir(): Promise<void> {
|
||||
if (!window.desktop?.openLogDir) return;
|
||||
const ok = await window.desktop.openLogDir();
|
||||
if (!ok) ElMessage.warning('打开日志目录失败,请按上面的路径手动前往');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 更新 */
|
||||
|
||||
/**
|
||||
* 更新检查。
|
||||
*
|
||||
* 「立即检查」走 `checkForUpdates(true)`:**强制绕过主进程那 30 分钟缓存**。
|
||||
* 这是本页与「关于」页的区别 —— 那边用户点检查也可能是缓存结果(省流量),
|
||||
* 这边维护者要的是"现在到底拿得到什么",所以按钮默认就是强制的。
|
||||
*/
|
||||
const checking = ref(false);
|
||||
const updateInfo = ref<UpdateInfo | null>(null);
|
||||
const ignoredUpdateVersion = ref('');
|
||||
|
||||
async function checkUpdate(force: boolean): Promise<void> {
|
||||
if (!window.desktop?.checkForUpdates) return;
|
||||
checking.value = true;
|
||||
try {
|
||||
updateInfo.value = await window.desktop.checkForUpdates(force);
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIgnoredVersion(): Promise<void> {
|
||||
if (!window.desktop?.getConfig) return;
|
||||
const stored = await window.desktop.getConfig().catch(() => null);
|
||||
ignoredUpdateVersion.value = stored?.ignoredUpdateVersion ?? '';
|
||||
}
|
||||
|
||||
async function ignoreVersion(): Promise<void> {
|
||||
const version = updateInfo.value?.latestVersion;
|
||||
if (!version || !window.desktop?.ignoreUpdateVersion) return;
|
||||
await window.desktop.ignoreUpdateVersion(version);
|
||||
ignoredUpdateVersion.value = version;
|
||||
ElMessage.success(`已忽略 v${version}:启动时的自动检查不再提示它`);
|
||||
}
|
||||
|
||||
/** 忽略是可逆的(排查线上"用户说不再提示"时经常要把它清掉),顺手给一个恢复入口 */
|
||||
async function clearIgnoredVersion(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
const saved = await window.desktop.saveConfig({ ignoredUpdateVersion: '' });
|
||||
ignoredUpdateVersion.value = saved.ignoredUpdateVersion;
|
||||
ElMessage.success('已恢复该版本的更新提示');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 调试工具 */
|
||||
|
||||
const openingDevTools = ref(false);
|
||||
|
||||
/**
|
||||
* 打开 DevTools(主进程 detached 独立窗口)。
|
||||
* 冒烟验证模式下主进程会拒绝(多一个窗口会把自动化断言打乱),返回 false —— 这里如实提示。
|
||||
*/
|
||||
async function openDevTools(): Promise<void> {
|
||||
if (!window.desktop?.openDevTools) return;
|
||||
openingDevTools.value = true;
|
||||
try {
|
||||
const ok = await window.desktop.openDevTools();
|
||||
if (ok) ElMessage.success('已在独立窗口打开开发者工具');
|
||||
else ElMessage.warning('当前是冒烟验证模式,主进程不允许打开 DevTools');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '打开开发者工具失败');
|
||||
} finally {
|
||||
openingDevTools.value = 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 clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll();
|
||||
// 带出启动自动检查的结果(这次命中主进程缓存,不会真的再发一次请求)
|
||||
void checkUpdate(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">开发者</h2>
|
||||
<p class="page-subtitle">版本与平台、设备上报、日志、更新与调试工具</p>
|
||||
</div>
|
||||
<el-button :loading="loading" data-test="dev-refresh" @click="loadAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
class="mb-12"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="仅供排查问题"
|
||||
description="这一页显示的是原始状态与内部路径,不需要给日常使用者看。关闭「通用 → 开发者模式」后它就会从侧栏消失。"
|
||||
/>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<!-- 版本与平台 -->
|
||||
<el-card shadow="never">
|
||||
<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 ?? '—' }} · Chromium {{ appInfo?.chrome ?? '—' }} · Node
|
||||
{{ appInfo?.node ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户数据目录">
|
||||
<span class="mono">{{ appInfo?.userDataPath ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置文件">
|
||||
<span class="mono">{{ appInfo?.configPath ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="冒烟验证模式">
|
||||
<el-tag :type="appInfo?.smokeTest ? 'warning' : 'info'" size="small" effect="plain">
|
||||
{{ appInfo?.smokeTest ? '是(自动化验证中)' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</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="连接状态">
|
||||
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
|
||||
{{ agent?.online ? '在线(近期上报成功)' : '离线(等待上报)' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备标识尾号">
|
||||
<span class="mono">{{ agent?.deviceKeyTail || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端目标版本">
|
||||
{{ agent?.updateTargetVersion || '无(保持当前版本)' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="待安装版本">
|
||||
{{ agent?.pendingUpdateVersion || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近错误">
|
||||
<span :class="{ 'text-danger': lastErrorText !== '无' }">{{ lastErrorText }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button type="primary" :loading="beating" data-test="dev-beat-now" @click="beatNow">
|
||||
立即上报
|
||||
</el-button>
|
||||
<el-button :loading="loading" @click="loadAll">刷新状态</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<!-- 日志 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>日志</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="日志文件">
|
||||
<span class="mono">{{ logPathText }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="日志级别">
|
||||
<el-tag :type="ui.developerMode ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ ui.developerMode ? 'debug(开发者模式)' : 'info' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted mt-12">
|
||||
单个日志文件 1MB 轮转:写满后改名为 main.old.log,新内容写进 main.log。
|
||||
所以"日志里找不到某次崩溃"多半是被轮转覆盖了,不是没写。
|
||||
</p>
|
||||
<!-- 不在这里用 <strong> 强调:中文整行的显示宽度是字符数的两倍,
|
||||
而 Vue 模板里夹了行内元素的长句会被 Prettier 折行并插入空格,
|
||||
渲染出来句子中间就多一个空格。要强调就单独起一行文字。 -->
|
||||
<p v-if="ui.developerMode" class="text-muted">
|
||||
debug 级日志已开启:级别在主进程启动时读取,改完开关后需重启客户端才生效。
|
||||
</p>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button data-test="dev-open-log-dir" @click="openLogDir">打开日志目录</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 更新 -->
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>更新</span></template>
|
||||
<div class="toolbar">
|
||||
<el-button :loading="checking" data-test="dev-check-update" @click="checkUpdate(true)">
|
||||
强制检查更新
|
||||
</el-button>
|
||||
<span class="text-muted">绕过主进程 30 分钟缓存,直接问 GitHub</span>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" class="mt-12">
|
||||
<el-descriptions-item label="本次结果">
|
||||
<template v-if="checking">正在检查…</template>
|
||||
<template v-else-if="!updateInfo">尚未检查</template>
|
||||
<template v-else-if="!updateInfo.ok">
|
||||
<span class="text-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
|
||||
</template>
|
||||
<template v-else-if="updateInfo.hasUpdate">
|
||||
<el-tag size="small" type="success" effect="light">
|
||||
有新版本 v{{ updateInfo.latestVersion }}
|
||||
</el-tag>
|
||||
<span class="text-muted">当前 v{{ updateInfo.currentVersion }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
|
||||
<span class="text-muted">v{{ updateInfo.currentVersion }}</span>
|
||||
</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="检查时间">
|
||||
{{ updateInfo?.checkedAt ? formatDate(updateInfo.checkedAt, true) : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已忽略的版本">
|
||||
<template v-if="ignoredUpdateVersion">
|
||||
<span class="mono">v{{ ignoredUpdateVersion }}</span>
|
||||
<el-button link type="primary" size="small" @click="clearIgnoredVersion">恢复提示</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">无</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button
|
||||
:disabled="!updateInfo?.latestVersion"
|
||||
data-test="dev-ignore-version"
|
||||
@click="ignoreVersion"
|
||||
>
|
||||
不再提示该版本
|
||||
</el-button>
|
||||
<span class="text-muted">写入配置后,启动时的自动检查会跳过它</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 调试工具 -->
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>调试工具</span></template>
|
||||
<div class="toolbar">
|
||||
<el-button :loading="openingDevTools" data-test="dev-open-devtools" @click="openDevTools">
|
||||
打开 DevTools
|
||||
</el-button>
|
||||
<span class="text-muted">独立窗口;冒烟验证模式下不允许打开</span>
|
||||
</div>
|
||||
<el-alert class="mt-12" type="info" :closable="false" show-icon title="灵动岛诊断(命令行)">
|
||||
<template #default>
|
||||
<p class="diag-copy">灵动岛开合"闪一下"这类问题要的是整屏逐帧取证,界面上给不出这个按钮。</p>
|
||||
<p class="diag-copy">跑冒烟时带上环境变量即可:</p>
|
||||
<p class="diag-copy mono">ELECTRON_SMOKE_ISLAND_DIAG=1 pnpm verify:desktop</p>
|
||||
<p class="diag-copy">它会在常规断言之外,对触摸/鼠标两种模式各跑一次收回动画的逐帧取证,</p>
|
||||
<p class="diag-copy">帧图与逐帧数据落在 `.cache/island-closing-frames/`。</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
|
||||
<!-- 离线缓存 -->
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<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 @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 路径与版本号这类"要抄下来发给别人"的值统一用等宽字体(避免 0/O、1/l 看混),
|
||||
* 并单独放开文本选中 —— body 上关掉了整页选中(更像原生应用),
|
||||
* 但这一页的路径就是要让人复制的。
|
||||
*
|
||||
* 字体栈写死而不新建 `--ch-font-mono` 令牌:全局只有这一处在用,
|
||||
* 为它加一个主题令牌(还得在 html.dark 里镜像一份)不划算。
|
||||
*/
|
||||
.mono {
|
||||
font-family: 'Cascadia Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 12.5px;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.diag-copy {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.diag-copy:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
formatDate,
|
||||
relativeTime,
|
||||
} from '@classhelper/shared';
|
||||
import type { ClientAgentStatus } from '../../../types/desktop.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
||||
import { normalizeServerUrl } from '../../config.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
@@ -14,6 +16,7 @@ import { useAuthStore } from '../../stores/auth.js';
|
||||
import { useNotificationStore } from '../../stores/notifications.js';
|
||||
import { useOnboardingStore } from '../../stores/onboarding.js';
|
||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||
import { useUiStore } from '../../stores/ui.js';
|
||||
|
||||
/**
|
||||
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
||||
@@ -24,6 +27,7 @@ const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
@@ -153,9 +157,193 @@ async function resetPhrases(): Promise<void> {
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 启动与更新 / 服务端联动 */
|
||||
|
||||
/**
|
||||
* 开机自启与自动更新。
|
||||
*
|
||||
* 开机自启的"是否生效"以**系统登录项**为准(新增的 `getAutoLaunch` 桥读的是
|
||||
* `app.getLoginItemSettings()`,不是配置文件里的值)—— 用户可能在任务管理器的「启动」页
|
||||
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
|
||||
*/
|
||||
const autoLaunch = ref(false);
|
||||
const autoUpdate = ref(true);
|
||||
const savingLaunch = ref(false);
|
||||
const savingUpdate = ref(false);
|
||||
|
||||
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
|
||||
const agent = ref<ClientAgentStatus | null>(null);
|
||||
const agentLoading = ref(false);
|
||||
/** 本机客户端版本(与服务端下发的目标版本对照着看) */
|
||||
const appVersion = ref('—');
|
||||
|
||||
/** 最近上报时间的相对描述(没有上报过就显示占位符) */
|
||||
const lastHeartbeatText = computed(() => {
|
||||
const value = agent.value?.lastHeartbeatAt;
|
||||
if (!value) return '还没有上报过';
|
||||
return relativeTime(value);
|
||||
});
|
||||
|
||||
/** 服务端对本机版本的期望:空串表示"不需要更新" */
|
||||
const updateTargetText = computed(() => {
|
||||
const target = agent.value?.updateTargetVersion ?? '';
|
||||
if (!target) return '无(保持当前版本)';
|
||||
return target;
|
||||
});
|
||||
|
||||
const pendingUpdateText = computed(() => {
|
||||
const pending = agent.value?.pendingUpdateVersion ?? '';
|
||||
if (!pending) return '无';
|
||||
return `${pending}(已下载,空闲时自动安装)`;
|
||||
});
|
||||
|
||||
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
||||
|
||||
async function loadLaunchSettings(): Promise<void> {
|
||||
if (!window.desktop?.getConfig) return;
|
||||
const config = await window.desktop.getConfig();
|
||||
autoUpdate.value = config.autoUpdate !== false;
|
||||
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
|
||||
const info = await window.desktop.getAppInfo?.().catch(() => null);
|
||||
if (info?.appVersion) appVersion.value = info.appVersion;
|
||||
}
|
||||
|
||||
async function toggleAutoLaunch(value: string | number | boolean): Promise<void> {
|
||||
if (!window.desktop?.setAutoLaunch) return;
|
||||
const wanted = value === true;
|
||||
autoLaunch.value = wanted;
|
||||
savingLaunch.value = true;
|
||||
try {
|
||||
const effective = await window.desktop.setAutoLaunch(wanted);
|
||||
// 以系统实际生效值为准:写注册表失败时会与用户点的不一致,必须回显真实状态
|
||||
autoLaunch.value = effective;
|
||||
if (effective !== wanted) ElMessage.warning('系统未接受开机自启设置,请检查任务管理器里的启动项');
|
||||
else ElMessage.success(wanted ? '已开启开机自启' : '已关闭开机自启');
|
||||
} catch (error) {
|
||||
autoLaunch.value = !wanted;
|
||||
ElMessage.error(error instanceof Error ? error.message : '设置开机自启失败');
|
||||
} finally {
|
||||
savingLaunch.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
const wanted = value === true;
|
||||
autoUpdate.value = wanted;
|
||||
savingUpdate.value = true;
|
||||
try {
|
||||
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
|
||||
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
|
||||
autoUpdate.value = saved.autoUpdate !== false;
|
||||
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
|
||||
} catch (error) {
|
||||
autoUpdate.value = !wanted;
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
|
||||
} finally {
|
||||
savingUpdate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
|
||||
|
||||
/**
|
||||
* 关怀模式(需求 14):放大**正文与控件**字号。
|
||||
*
|
||||
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
|
||||
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
|
||||
*
|
||||
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
|
||||
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
|
||||
*
|
||||
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
|
||||
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
|
||||
*/
|
||||
const savingCareMode = ref(false);
|
||||
|
||||
async function toggleCareMode(value: string | number | boolean): Promise<void> {
|
||||
const wanted = value === true;
|
||||
savingCareMode.value = true;
|
||||
try {
|
||||
await ui.setCareMode(wanted);
|
||||
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
|
||||
} finally {
|
||||
savingCareMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
|
||||
*
|
||||
* 打开后侧栏「设置」分组里**立刻**多出「开发者」页(`ClientLayout.vue` 读的是同一个
|
||||
* ui store 状态),下次启动时主进程会把日志级别调到 debug。
|
||||
*/
|
||||
const savingDeveloperMode = ref(false);
|
||||
|
||||
async function toggleDeveloperMode(value: string | number | boolean): Promise<void> {
|
||||
const wanted = value === true;
|
||||
savingDeveloperMode.value = true;
|
||||
try {
|
||||
await ui.setDeveloperMode(wanted);
|
||||
ElMessage.success(wanted ? '已开启开发者模式:侧栏出现「开发者」页' : '已关闭开发者模式');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存开发者模式设置失败');
|
||||
} finally {
|
||||
savingDeveloperMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAgentStatus(): Promise<void> {
|
||||
if (!window.desktop?.getClientAgentStatus) return;
|
||||
agentLoading.value = true;
|
||||
try {
|
||||
agent.value = await window.desktop.getClientAgentStatus();
|
||||
} finally {
|
||||
agentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function beatNow(): Promise<void> {
|
||||
if (!window.desktop?.beatClientNow) return;
|
||||
agentLoading.value = true;
|
||||
try {
|
||||
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
|
||||
agent.value = await window.desktop.beatClientNow();
|
||||
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
|
||||
else ElMessage.success('已向服务端上报本机状态');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
|
||||
} finally {
|
||||
agentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogDir(): Promise<void> {
|
||||
if (!window.desktop?.openLogDir) return;
|
||||
const ok = await window.desktop.openLogDir();
|
||||
if (!ok) ElMessage.warning('打开日志目录失败,可在「关于」页复制诊断信息');
|
||||
}
|
||||
|
||||
/** 状态里的相对时间要跟着走,否则挂着的页面会一直显示"刚刚" */
|
||||
let statusTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.refreshCacheStats();
|
||||
await loadPhrases();
|
||||
await loadLaunchSettings();
|
||||
await refreshAgentStatus();
|
||||
statusTimer = setInterval(() => {
|
||||
void refreshAgentStatus();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (statusTimer !== null) {
|
||||
clearInterval(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -238,6 +426,77 @@ onMounted(async () => {
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>启动与更新</span></template>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>开机自启</span>
|
||||
<p class="text-muted">登录 Windows 后自动启动 ClassHelper;教室机器建议打开。</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="autoLaunch"
|
||||
:loading="savingLaunch"
|
||||
@update:model-value="toggleAutoLaunch"
|
||||
/>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>自动更新</span>
|
||||
<p class="text-muted">
|
||||
安装只在
|
||||
<strong>不上课且窗口已隐藏 5 分钟后</strong>
|
||||
进行,下载随时可能发生。
|
||||
</p>
|
||||
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="autoUpdate"
|
||||
:loading="savingUpdate"
|
||||
@update:model-value="toggleAutoUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button @click="openLogDir">打开日志目录</el-button>
|
||||
<span class="text-muted">日志文件 main.log,排查问题时可发给管理员</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>显示与调试</span></template>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>关怀模式</span>
|
||||
<p class="text-muted">
|
||||
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
|
||||
</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="ui.careMode"
|
||||
:loading="savingCareMode"
|
||||
data-test="care-mode-switch"
|
||||
@update:model-value="toggleCareMode"
|
||||
/>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>开发者模式</span>
|
||||
<p class="text-muted">显示调试页面与详细日志,仅排查问题时打开。</p>
|
||||
<p v-if="ui.developerMode" class="text-muted">
|
||||
debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
|
||||
</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="ui.developerMode"
|
||||
:loading="savingDeveloperMode"
|
||||
data-test="developer-mode-switch"
|
||||
@update:model-value="toggleDeveloperMode"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
@@ -265,6 +524,35 @@ onMounted(async () => {
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>服务端联动状态</span></template>
|
||||
<p class="text-muted">
|
||||
本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
|
||||
服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
|
||||
</p>
|
||||
<el-descriptions class="mt-12" :column="1" border size="small">
|
||||
<el-descriptions-item label="连接状态">
|
||||
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
|
||||
{{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="本机版本">
|
||||
{{ appVersion }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备标识尾号">
|
||||
{{ agent?.deviceKeyTail || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端目标版本">{{ updateTargetText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已下载待安装">{{ pendingUpdateText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近错误">{{ lastErrorText }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button type="primary" :loading="agentLoading" @click="beatNow">立即上报</el-button>
|
||||
<el-button :loading="agentLoading" @click="refreshAgentStatus">刷新状态</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -282,4 +570,29 @@ onMounted(async () => {
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 开关行:左说明右开关,说明长时自动换行(「自动更新」的提示是两行) */
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.switch-row + .switch-row {
|
||||
border-top: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.switch-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.switch-main p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user