功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
255 lines
8.8 KiB
TypeScript
255 lines
8.8 KiB
TypeScript
import { computed, ref } from 'vue';
|
||
import { defineStore } from 'pinia';
|
||
import {
|
||
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||
STORAGE_KEYS,
|
||
formatDate,
|
||
type ClassIslandNotificationChannel,
|
||
} from '@classhelper/shared';
|
||
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
|
||
import { classChannelApi } from '../api/index.js';
|
||
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
|
||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||
|
||
type RecoveryHandler = () => void;
|
||
|
||
/**
|
||
* 「本次离线从什么时候开始」的本地时间戳键(需求 11)。
|
||
*
|
||
* 为什么用 localStorage 而不是主进程配置(config.json):
|
||
* 它是一个**瞬时状态**,只描述"当前这一轮断网",一旦补弹完成(或进程重启后重新联网)就作废,
|
||
* 不需要跨进程读、也不需要跟着设备身份走。放 localStorage 有三点好处:
|
||
* - 写入发生在每次请求的**可达性回调**里(高频路径),localStorage 是同步内存写,
|
||
* 不会像 saveConfig 那样排一次主进程 IPC + 120ms 合并窗口;
|
||
* - 它是**渲染进程自己**的读-改-清闭环(bridge.ts 读它 → 补弹 → 清掉),不跨越 contextBridge;
|
||
* - 跨重启也够用:网页存储随 profile 持久化,补弹窗口本来就只关心"最近一次离线"。
|
||
*/
|
||
const OFFLINE_SINCE_KEY = 'classhelper.offlineSince';
|
||
|
||
/**
|
||
* 记下"本次离线从什么时候开始"(ISO 字符串)。
|
||
* 读不到 / 写不进(隐私模式、存储被清)时静默跳过 —— 补弹只是锦上添花,不该影响可达性上报。
|
||
*/
|
||
function markOfflineSince(): void {
|
||
try {
|
||
const now = new Date().toISOString();
|
||
localStorage.setItem(OFFLINE_SINCE_KEY, now);
|
||
console.info(`[离线] 已记录本次离线起点:${now}`);
|
||
} catch {
|
||
// 存储不可用:这次不补弹,但联网后的恢复流程照常
|
||
}
|
||
}
|
||
|
||
/** 读本次离线起点(ISO 字符串);没有记录(首次启动 / 一直在线)返回 null */
|
||
export function readOfflineSince(): string | null {
|
||
try {
|
||
return localStorage.getItem(OFFLINE_SINCE_KEY);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 清掉离线起点:补弹完成后调用,避免同一次离线被重复补弹 */
|
||
export function clearOfflineSince(): void {
|
||
try {
|
||
localStorage.removeItem(OFFLINE_SINCE_KEY);
|
||
} catch {
|
||
// 清不掉也不影响功能:下一次"由可达变不可达"会覆盖它
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
|
||
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
|
||
*/
|
||
export const useAppStore = defineStore('app', () => {
|
||
const serverUrl = ref(DEFAULT_SERVER);
|
||
const serverReachable = ref(false);
|
||
const lastSyncAt = ref<number | null>(null);
|
||
const cacheStoreStats = ref<CacheStoreStat[]>([]);
|
||
const currentWeek = ref(1);
|
||
const maxWeek = ref(20);
|
||
const initialized = ref(false);
|
||
/**
|
||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||
*
|
||
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
|
||
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
|
||
*/
|
||
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
|
||
|
||
const offline = computed(() => !serverReachable.value);
|
||
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
|
||
const cacheItemCount = computed(() => cacheStoreStats.value.reduce((sum, item) => sum + item.count, 0));
|
||
|
||
const recoveryHandlers = new Set<RecoveryHandler>();
|
||
let healthTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
function onServerRecovered(handler: RecoveryHandler): void {
|
||
recoveryHandlers.add(handler);
|
||
}
|
||
|
||
function offServerRecovered(handler: RecoveryHandler): void {
|
||
recoveryHandlers.delete(handler);
|
||
}
|
||
|
||
/**
|
||
* 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性。
|
||
*
|
||
* 「由可达变不可达」的那一刻顺手记下离线起点(需求 11 的补弹基准):
|
||
* 只在**边界**上写,不在"一直不可达"的每次请求上重复覆盖,否则补弹窗口会被不停地
|
||
* 推迟到"最后一次失败",离线期间收到的通知就永远落在窗口之外了。
|
||
*/
|
||
setReachabilityReporter((reachable) => {
|
||
if (reachable && !serverReachable.value && initialized.value) {
|
||
serverReachable.value = true;
|
||
notifyRecovered();
|
||
return;
|
||
}
|
||
if (!reachable && serverReachable.value && initialized.value) {
|
||
markOfflineSince();
|
||
}
|
||
serverReachable.value = reachable;
|
||
});
|
||
|
||
function notifyRecovered(): void {
|
||
for (const handler of recoveryHandlers) handler();
|
||
}
|
||
|
||
async function ping(): Promise<void> {
|
||
const result = await pingHealth();
|
||
if (result.ok) {
|
||
if (!serverReachable.value && initialized.value) notifyRecovered();
|
||
serverReachable.value = true;
|
||
} else {
|
||
// 健康探针是机器**空闲时唯一**的可达性信号(没有任何业务请求在跑),
|
||
// 所以这条路径也要记离线起点,否则空闲断网期间收到的通知补不回来。
|
||
if (serverReachable.value && initialized.value) markOfflineSince();
|
||
serverReachable.value = false;
|
||
}
|
||
}
|
||
|
||
function startHealthPolling(): void {
|
||
if (healthTimer) return;
|
||
healthTimer = setInterval(() => {
|
||
void ping();
|
||
}, 20000);
|
||
}
|
||
|
||
function stopHealthPolling(): void {
|
||
if (!healthTimer) return;
|
||
clearInterval(healthTimer);
|
||
healthTimer = null;
|
||
}
|
||
|
||
async function applyServerUrl(next: string): Promise<boolean> {
|
||
const normalized = normalizeServerUrl(next);
|
||
if (!normalized) return false;
|
||
serverUrl.value = normalized;
|
||
setApiBaseUrl(normalized);
|
||
localStorage.setItem(STORAGE_KEYS.serverUrl, normalized);
|
||
if (window.desktop) await window.desktop.saveConfig({ serverUrl: normalized });
|
||
return true;
|
||
}
|
||
|
||
async function refreshCacheStats(): Promise<void> {
|
||
try {
|
||
cacheStoreStats.value = await cacheStats();
|
||
} catch {
|
||
cacheStoreStats.value = [];
|
||
}
|
||
}
|
||
|
||
async function clearCache(): Promise<void> {
|
||
await cacheClearAll();
|
||
await refreshCacheStats();
|
||
}
|
||
|
||
async function init(serverFromConfig: string): Promise<void> {
|
||
if (serverFromConfig) await applyServerUrl(serverFromConfig);
|
||
await refreshCacheStats();
|
||
await ping();
|
||
startHealthPolling();
|
||
initialized.value = true;
|
||
}
|
||
|
||
function markSynced(): void {
|
||
lastSyncAt.value = Date.now();
|
||
void refreshCacheStats();
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 通知显示位置 */
|
||
|
||
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
|
||
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
|
||
notificationChannel.value = value;
|
||
}
|
||
|
||
/**
|
||
* 向服务器对齐显示位置。
|
||
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
|
||
*/
|
||
async function loadNotificationChannel(classId: string | null): Promise<void> {
|
||
if (!classId) return;
|
||
try {
|
||
const result = await classChannelApi.get(classId);
|
||
notificationChannel.value = result.notificationChannel;
|
||
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
|
||
} catch {
|
||
// 离线/无权限:保留本地值,不影响展示
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 改显示位置:先落本地(立刻生效),再写回班级记录。
|
||
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
|
||
*/
|
||
async function setNotificationChannel(
|
||
classId: string | null,
|
||
value: ClassIslandNotificationChannel,
|
||
): Promise<boolean> {
|
||
notificationChannel.value = value;
|
||
await window.desktop?.saveConfig({ notificationChannel: value });
|
||
if (!classId) return false;
|
||
try {
|
||
const result = await classChannelApi.set(classId, value);
|
||
notificationChannel.value = result.notificationChannel;
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
|
||
function shouldPopupLocally(): boolean {
|
||
return notificationChannel.value !== 'classisland';
|
||
}
|
||
|
||
return {
|
||
serverUrl,
|
||
serverReachable,
|
||
offline,
|
||
lastSyncAt,
|
||
lastSyncText,
|
||
cacheStoreStats,
|
||
cacheItemCount,
|
||
currentWeek,
|
||
maxWeek,
|
||
initialized,
|
||
notificationChannel,
|
||
init,
|
||
ping,
|
||
applyServerUrl,
|
||
applyLocalNotificationChannel,
|
||
loadNotificationChannel,
|
||
setNotificationChannel,
|
||
shouldPopupLocally,
|
||
refreshCacheStats,
|
||
clearCache,
|
||
markSynced,
|
||
onServerRecovered,
|
||
offServerRecovered,
|
||
stopHealthPolling,
|
||
};
|
||
});
|