fix(desktop-client): 修复灵动岛可能再也不显示的两个隐患(hide/show 竞态导致误判已显示、显示器变化后跑到屏外),新增真实通知链路冒烟校验、设置页灵动岛自检面板与日志落盘

This commit is contained in:
LoveChengke committed 2026-09-12 11:19:13 +08:00
1 parent f759aac0a2
commit 828fde80f9
6 files changed
+378 -14

No files matched your search

+42 -2
View File
@@ -62,6 +62,15 @@ class IslandController {
private boundsToken = 0; private boundsToken = 0;
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */ /** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
private fadeToken = 0; private fadeToken = 0;
/**
* 自己维护的"窗口当前是否显示"标记。
*
* 不直接用 `win.isVisible()`:Windows 上 hide/show 是异步生效的,
* 在 hide() 刚调用完的瞬间 isVisible() 仍可能返回 true。若此时来了一条新通知,
* 逻辑会误判成"窗口已经显示",于是只做尺寸动画、不调用 show(),
* 结果灵动岛再也不出现(真机上表现为"灵动岛消失了")。
*/
private shown = false;
private state: IslandState = { private state: IslandState = {
mode: 'hidden', mode: 'hidden',
@@ -115,6 +124,24 @@ class IslandController {
win.on('closed', () => { win.on('closed', () => {
this.win = null; this.win = null;
this.ready = false; this.ready = false;
this.shown = false;
});
win.on('hide', () => {
this.shown = false;
});
win.on('show', () => {
this.shown = true;
});
// 显示器变化(外接屏插拔/改分辨率)后重算顶部居中位置,避免灵动岛"跑"到屏幕外
const reposition = (): void => this.reposition();
screen.on('display-metrics-changed', reposition);
screen.on('display-added', reposition);
screen.on('display-removed', reposition);
win.on('closed', () => {
screen.off('display-metrics-changed', reposition);
screen.off('display-added', reposition);
screen.off('display-removed', reposition);
}); });
this.win = win; this.win = win;
@@ -308,6 +335,7 @@ class IslandController {
this.emit(); this.emit();
} }
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
this.shown = false;
if (this.win.isVisible()) this.win.hide(); if (this.win.isVisible()) this.win.hide();
this.win.setOpacity(1); this.win.setOpacity(1);
} }
@@ -438,7 +466,7 @@ class IslandController {
: SIZES.expanded : SIZES.expanded
: SIZES.pill; : SIZES.pill;
if (!this.win.isVisible()) { if (!this.shown) {
const pillBounds = this.computeBounds(SIZES.pill); const pillBounds = this.computeBounds(SIZES.pill);
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画 // 普通通知:直接出现在屏幕上,不做"上岛"入场动画
if (size === SIZES.pill) { if (size === SIZES.pill) {
@@ -446,6 +474,7 @@ class IslandController {
this.win.setOpacity(1); this.win.setOpacity(1);
this.win.setBounds(pillBounds); this.win.setBounds(pillBounds);
this.win.showInactive(); this.win.showInactive();
this.shown = true;
return; return;
} }
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见 // 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
@@ -453,6 +482,7 @@ class IslandController {
this.win.setOpacity(0); this.win.setOpacity(0);
this.win.setBounds(pillBounds); this.win.setBounds(pillBounds);
this.win.showInactive(); this.win.showInactive();
this.shown = true;
this.animateBounds(size, { spring: true }); this.animateBounds(size, { spring: true });
this.fadeIn(); this.fadeIn();
return; return;
@@ -460,6 +490,15 @@ class IslandController {
this.animateBounds(size, { spring: size !== SIZES.pill }); this.animateBounds(size, { spring: size !== SIZES.pill });
} }
/** 显示器参数变化后把窗口重新摆到当前主屏顶部居中 */
private reposition(): void {
if (!this.win || this.win.isDestroyed()) return;
const size = this.win.getBounds();
const target = this.computeBounds({ width: size.width, height: size.height });
this.boundsToken += 1;
this.win.setBounds(target);
}
/** 作废进行中的淡入淡出动画 */ /** 作废进行中的淡入淡出动画 */
private cancelFade(): void { private cancelFade(): void {
this.fadeToken += 1; this.fadeToken += 1;
@@ -479,7 +518,7 @@ class IslandController {
} }
private fadeOut(): void { private fadeOut(): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return; if (!this.win || this.win.isDestroyed() || !this.shown) return;
const token = ++this.fadeToken; const token = ++this.fadeToken;
let opacity = this.win.getOpacity(); let opacity = this.win.getOpacity();
const step = (): void => { const step = (): void => {
@@ -490,6 +529,7 @@ class IslandController {
setTimeout(step, 16); setTimeout(step, 16);
return; return;
} }
this.shown = false;
this.win.hide(); this.win.hide();
this.win.setOpacity(1); this.win.setOpacity(1);
}; };
+60 -5
View File
@@ -1,25 +1,80 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
/** /**
* 主进程日志(控制台输出)。 * 主进程日志。
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过: *
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件 * 打包后的 GUI 进程没有可见的控制台,所以除了 console 输出,
* - 主窗口右上角的连接状态 / 灵动岛状态 * 关键信息还会追加写入 `%APPDATA%/<app>/logs/client.log`(超过 1MB 自动轮转一次),
* 对外可见,避免"出了问题却看不到日志"。 * 便于出问题时(例如"灵动岛去哪了")直接查看日志文件。
*/ */
const MAX_LOG_BYTES = 1024 * 1024;
let logFile: string | null = null;
let initialized = false;
function timestamp(): string { function timestamp(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
function ensureLogFile(): string | null {
if (initialized) return logFile;
initialized = true;
try {
const dir = path.join(app.getPath('userData'), 'logs');
fs.mkdirSync(dir, { recursive: true });
logFile = path.join(dir, 'client.log');
if (fs.existsSync(logFile) && fs.statSync(logFile).size > MAX_LOG_BYTES) {
fs.renameSync(logFile, path.join(dir, 'client.log.1'));
}
} catch {
logFile = null;
}
return logFile;
}
function write(level: string, message: string, args: unknown[]): void {
const extra = args.length > 0 ? ` ${args.map((item) => safeStringify(item)).join(' ')}` : '';
const line = `[${timestamp()}] ${level} ${message}${extra}\n`;
const file = ensureLogFile();
if (!file) return;
try {
fs.appendFileSync(file, line, 'utf8');
} catch {
/* 日志写失败不能影响主流程 */
}
}
function safeStringify(value: unknown): string {
if (value instanceof Error) return `${value.name}: ${value.message}`;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
export const logger = { export const logger = {
debug(message: string, ...args: unknown[]): void { debug(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] DEBUG ${message}`, ...args); console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
write('DEBUG', message, args);
}, },
info(message: string, ...args: unknown[]): void { info(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] INFO ${message}`, ...args); console.log(`[${timestamp()}] INFO ${message}`, ...args);
write('INFO ', message, args);
}, },
warn(message: string, ...args: unknown[]): void { warn(message: string, ...args: unknown[]): void {
console.warn(`[${timestamp()}] WARN ${message}`, ...args); console.warn(`[${timestamp()}] WARN ${message}`, ...args);
write('WARN ', message, args);
}, },
error(message: string, ...args: unknown[]): void { error(message: string, ...args: unknown[]): void {
console.error(`[${timestamp()}] ERROR ${message}`, ...args); console.error(`[${timestamp()}] ERROR ${message}`, ...args);
write('ERROR', message, args);
},
/** 当前日志文件路径(设置页/排查时展示) */
filePath(): string | null {
return ensureLogFile();
}, },
}; };
+79 -5
View File
@@ -82,14 +82,19 @@ function analyzeBitmap(image: NativeImage): {
return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish }; return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish };
} }
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */ /**
* 灵动岛状态截图留档 + 像素统计。
*
* 像素统计**总是执行**(断言不依赖环境变量);写盘留档仅在设置了
* `ISLAND_SHOTS_DIR` 时进行(用于 docs/screenshots 归档与人工复核)。
*/
async function captureIsland( async function captureIsland(
name: string, name: string,
expected: { width: number; height: number }, expected: { width: number; height: number },
): Promise<IslandShotStats | null> { ): Promise<IslandShotStats | null> {
const dir = process.env.ISLAND_SHOTS_DIR; const dir = process.env.ISLAND_SHOTS_DIR ?? '';
const win = island.getWindow(); const win = island.getWindow();
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null; if (!win || win.isDestroyed() || !win.isVisible()) return null;
try { try {
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图 // 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
await win.webContents.executeJavaScript( await win.webContents.executeJavaScript(
@@ -100,9 +105,12 @@ async function captureIsland(
await win.webContents.executeJavaScript( await win.webContents.executeJavaScript(
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`, `document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
); );
const filePath = dir ? path.join(dir, `${name}.png`) : '';
if (filePath) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${name}.png`);
fs.writeFileSync(filePath, image.toPNG()); fs.writeFileSync(filePath, image.toPNG());
}
const size = image.getSize(); const size = image.getSize();
const stats: IslandShotStats = { const stats: IslandShotStats = {
@@ -115,7 +123,7 @@ async function captureIsland(
}; };
islandShots.push(stats); islandShots.push(stats);
console.log( console.log(
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` + `[SMOKE] 灵动岛截图:${filePath || '(仅内存统计)'}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`, `颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`,
); );
return stats; return stats;
@@ -403,6 +411,67 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' }); results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
} }
/**
* 真实通知链路自检:教师发通知 → 服务端广播 → 客户端实时通道 → 灵动岛。
*
* 与 runIslandChecks 的区别:那里是直接调 IPC 验证状态机;这里走完
* "HTTP 发布 + Socket.IO 广播 + 渲染进程 realtime store + bridge + IPC" 全链路,
* 能抓到"状态机没问题但真机不弹"的问题。
*/
async function runIslandRealtimeCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
const scenario = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.islandRealtimeScenario();
})()`,
);
if (!scenario?.ok) {
return { ok: false, detail: `投递失败:${scenario?.detail ?? '未知原因'}` };
}
// 等通知经实时通道到达并弹出胶囊(实测 <100ms,这里留足余量)
const deadline = Date.now() + 10_000;
let state = island.getState();
while (Date.now() < deadline) {
state = island.getState();
if (state.mode === 'pill' && state.active?.id === scenario.notificationId) break;
await sleep(120);
}
const visible = island.getWindow()?.isVisible() ?? false;
const domText = await island
.getWindow()
?.webContents.executeJavaScript(
`(document.querySelector('.island-card.pill')?.textContent ?? '').replace(/\\s+/g, ' ').trim()`,
)
.catch(() => '');
await captureIsland('island-6-realtime', ISLAND_SIZES.pill);
const matched = state.mode === 'pill' && state.active?.id === scenario.notificationId;
const detail =
`通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` +
`灵动岛 mode=${state.mode} 可见=${visible} 文案="${String(domText).slice(0, 40)}"`;
// 清理:删除本次自检产生的通知,避免污染演示数据
const cleanup = await win.webContents
.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeCleanup) return { ok: false, detail: '缺少清理钩子' };
return await window.__classhelperSmoke__.islandRealtimeCleanup(
${JSON.stringify(scenario.notificationId)},
${JSON.stringify(scenario.teacherToken)},
);
})()`,
)
.catch(() => null);
console.log(`[SMOKE] 真实链路清理:${cleanup?.detail ?? '未执行'}`);
// 收尾:把胶囊收起来,避免影响后续用例
island.handleAction({ action: 'dismiss' });
await sleep(300);
return { ok: matched && visible, detail };
}
/** /**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。 * Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退, * 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -545,6 +614,11 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
String(online?.detail ?? ''), String(online?.detail ?? ''),
); );
// 真实通知链路:教师发通知 → 学生客户端实时通道收到 → 灵动岛弹出胶囊
// (前面的灵动岛用例只验证状态机,这一条验证"真机上通知真的会浮出来")
const realtime = await runIslandRealtimeCheck(win);
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.ok, realtime.detail);
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应) // 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
const navigation = await win.webContents.executeJavaScript( const navigation = await win.webContents.executeJavaScript(
`(async () => { `(async () => {
+84
View File
@@ -215,6 +215,88 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
} }
} }
/**
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
*
* 前面的灵动岛用例是直接调 IPC 验证"状态机",这一条走**真机链路**:
* 教师账号给"当前登录学生所在班级"发一条普通通知 → 服务端 Socket.IO 广播
* → 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊。
*
* 返回临时信息(通知 id / 教师令牌 / 标题)供主进程断言与收尾清理。
*/
export async function islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}> {
const [{ useAuthStore }, { getIslandClassContext }] = await Promise.all([
import('../stores/auth.js'),
import('../island/bridge.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
const title = `灵动岛真机链路自检 ${Date.now()}`;
try {
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json());
const teacherToken: string | undefined = login?.data?.token;
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
const created = await fetch(`${serverUrl}/api/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
body: JSON.stringify({
classId,
title,
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
priority: 'NORMAL',
}),
}).then((response) => response.json());
const notificationId: string | undefined = created?.data?.id;
if (!notificationId)
return { ok: false, detail: `发布通知失败:${JSON.stringify(created).slice(0, 160)}` };
const context = getIslandClassContext();
return {
ok: true,
detail: `classId=${classId} 通知=${notificationId} 客户端判定上课中=${context.inClass}`,
notificationId,
teacherToken,
title,
classId,
inClass: context.inClass,
};
} catch (error) {
return { ok: false, detail: `投递失败:${error instanceof Error ? error.message : String(error)}` };
}
}
/** 冒烟收尾:删除真实链路自检产生的通知 */
export async function islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<SmokeCheckResult> {
try {
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` },
});
return { ok: response.ok, detail: `清理通知 ${notificationId} → HTTP ${response.status}` };
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
}
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */ /** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> { export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([ const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -237,6 +319,8 @@ export function registerSmokeHooks(): void {
offlineScenario, offlineScenario,
layoutNavigationSelfTest, layoutNavigationSelfTest,
onlineScenario, onlineScenario,
islandRealtimeScenario,
islandRealtimeCleanup,
sessionCleanup, sessionCleanup,
}; };
} }
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate } from '@classhelper/shared'; import { PRIORITY_LABELS, formatDate, type IslandState } from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js'; import type { DesktopAppInfo } from '../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js'; import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js'; import { normalizeServerUrl } from '../config.js';
import { getIslandClassContext } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
@@ -102,9 +103,75 @@ async function logout(): Promise<void> {
await router.replace('/login'); await router.replace('/login');
} }
/* ------------------------------------------------------------ 灵动岛自检 */
const islandState = ref<IslandState | null>(null);
const islandClassContext = ref(getIslandClassContext());
let islandTimer: ReturnType<typeof setInterval> | null = null;
/** 主进程侧灵动岛状态(隐藏/胶囊/详情 + 待发条数 + 是否上课) */
async function refreshIslandState(): Promise<void> {
islandClassContext.value = getIslandClassContext();
if (!window.desktop?.islandGetState) return;
islandState.value = await window.desktop.islandGetState();
}
const islandModeLabel = computed(() => {
switch (islandState.value?.mode) {
case 'pill':
return '新消息胶囊';
case 'expanded':
return '展开详情';
case 'hidden':
return '隐藏(无通知时不显示)';
default:
return '未知';
}
});
const islandHint = computed(() => {
const state = islandState.value;
if (!state) return '灵动岛尚未就绪';
if (islandClassContext.value.inClass) {
return state.active?.priority === 'URGENT'
? '上课中:正在显示紧急通知'
: '上课中:通知会暂存,下课后自动弹出(紧急通知除外)';
}
if (state.queued.length > 0) return `有 ${state.queued.length} 条通知待查看`;
return '收到通知时会在这里浮出胶囊';
});
/** 本地推一条测试通知,立刻确认灵动岛能出现(走主进程,不依赖服务器) */
function testIsland(): void {
if (!window.desktop?.islandPush) {
ElMessage.error('当前环境不支持灵动岛');
return;
}
window.desktop.islandPush({
notification: {
id: `local-test-${Date.now()}`,
title: '灵动岛自检通知',
content: '如果你看到这条胶囊/卡片,说明灵动岛工作正常;点击它可以展开查看详情。',
priority: 'NORMAL',
createdAt: new Date().toISOString(),
courseName: null,
teacherName: '本地自检',
},
context: islandClassContext.value,
});
void refreshIslandState();
}
onMounted(async () => { onMounted(async () => {
await loadAppInfo(); await loadAppInfo();
await appStore.refreshCacheStats(); await appStore.refreshCacheStats();
await refreshIslandState();
islandTimer = setInterval(() => void refreshIslandState(), 2000);
});
onUnmounted(() => {
if (islandTimer) clearInterval(islandTimer);
islandTimer = null;
}); });
</script> </script>
@@ -150,6 +217,36 @@ onMounted(async () => {
</el-descriptions> </el-descriptions>
</el-card> </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="islandState?.mode === 'hidden' ? 'info' : 'success'" size="small">
{{ islandModeLabel }}
</el-tag>
<el-tag class="ml-8" :type="islandClassContext.inClass ? 'warning' : 'success'" size="small">
{{ islandClassContext.inClass ? '上课中(普通通知暂存)' : '非上课时段' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="窗口可见">
{{ islandState ? (islandState.mode === 'hidden' ? '否(无通知时隐藏)' : '是') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="待查看通知">
{{ islandState?.queued.length ?? 0 }} 条
<template v-if="islandState?.active">
· 当前:{{ islandState.active.title }}({{ PRIORITY_LABELS[islandState.active.priority] }})
</template>
</el-descriptions-item>
<el-descriptions-item label="说明">{{ islandHint }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" @click="testIsland">测试灵动岛</el-button>
<el-button @click="refreshIslandState">刷新状态</el-button>
</div>
</el-card>
<el-card shadow="never" class="mt-12"> <el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template> <template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small"> <el-descriptions :column="1" border size="small">
+14
View File
@@ -76,6 +76,20 @@ declare global {
offlineScenario(): Promise<{ ok: boolean; detail: string }>; offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>; layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(): Promise<{ ok: boolean; detail: string }>; onlineScenario(): Promise<{ ok: boolean; detail: string }>;
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}>;
islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>; sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
}; };
} }