fix(island): 幽灵胶囊自愈 + 叫人双广播不降级 + 淡出/帧限流兜底(继续修"点不动")

用户反馈"叫人点不动"复盘:状态机层面没问题(真机点击日志能看到"收到用户动作 expand"),
问题在窗口/渲染层面。本轮修掉三个真实缺陷:

1) 幽灵胶囊:岛渲染进程一旦崩溃/卡死,Windows 会把最后一帧留在屏幕上 —— 岛还挂在最上方、
   窗口也还在,但点击全部丢失,而客户端没有任何自愈机制(实测:崩掉渲染进程后点击不再产生
   任何动作)。现在渲染进程每 5s 上报心跳,主进程 20s 收不到、或收到 render-process-gone /
   unresponsive 时,**先隐藏死窗口**再重建并重新下发状态与外观;10 分钟内最多重建 3 次,
   超出则隐藏并告警,避免持续崩坏时无限重建。
2) 叫人双广播降级:服务端 createCall 同时发 notification:new(班级房间)与 call:new(user 房间),
   两条 DTO 同 id,后到的那条会把"叫人"覆盖成普通通知 —— 胶囊显示成
   "新消息 / 系统管理员 · 点击查看"(用户截图正是如此),紧急叫人还会被降级成"课中只排队、
   不立即展开"。现在按 id 合并:类型 call 优先、优先级取更高(URGENT > HIGH > NORMAL)。
3) 淡出竞态:岛重新变为可见时没有取消进行中的淡出,那次淡出会继续把透明度降到 0 并 hide()
   窗口,出现"状态是胶囊/展开、窗口却不可见"的错位(冒烟"岛外鼠标穿透"用例就是被它打红的)。
   现在可见路径总是 cancelFade + 恢复透明度。

另加两条兜底:
- 命中矩形兜底:渲染进程还没上报岛体矩形时,主进程按当前形态/停靠位置自己算一个,
  杜绝"岛可见却一直不接收鼠标"。
- 形变兜底:实测打包版某些时刻(细缝态、刚启动)rAF 会被 Chromium 压到约 1 帧/秒,
  此时"点击后形变"看起来就是没反应;现在形变开始 1.2s 后仍未收敛就直接 snap 到目标几何。

验证:pnpm verify:desktop 81/81(新增 3 条回归:双广播不降级 / 紧急叫人双广播 /
渲染进程崩溃后自愈;离线二次启动 73/73);打包版 verify:packaged 81/81、packaged=true、
二次启动 73/73,全绿。
顺带:细缝用例改为轮询等几何收敛(帧被限流时固定 sleep 会误判为"细缝尺寸不对"),
课表时间轴失败时打印各卡片状态(便于定位),并清理了库里 3 条遗留测试探针课表
(它们会污染客户端"今天"的课表)。
This commit is contained in:
LoveChengke committed 2026-09-26 20:47:01 +08:00
1 parent a7b0e9c2b3
commit 337d6eae53
6 files changed
+329 -10

No files matched your search

@@ -213,6 +213,10 @@ const progress = ref(0);
let rafHandle: number | null = null;
let lastFrameAt = 0;
/** 形变兜底定时器:rAF 被系统限流时用它 snap 到目标几何(见 startFrameLoop) */
let morphFallbackTimer: number | null = null;
/** 兜底等待时长:正常形变约 300~500ms,超出这个时间说明帧被限流了 */
const MORPH_FALLBACK_MS = 1200;
function syncTargets(snap = false): void {
const size = targetSize();
@@ -237,6 +241,24 @@ function syncTargets(snap = false): void {
function startFrameLoop(): void {
if (rafHandle !== null) return;
lastFrameAt = performance.now();
// 形变兜底:某些情况下(Windows 把置顶透明小窗判定为"被遮挡/后台"、GPU 进程刚重启等)
// requestAnimationFrame 会被 Chromium 压到 1 帧/秒 —— 实测打包版细缝态出现过 1.1fps。
// 那种情况下"点击后形变"看起来就是**没反应**(用户反馈的"点不动")。因此这里挂一个定时器,
// 到点若还没收敛就直接 snap 到目标几何:宁可少一段动画,也不能让点击看上去无效。
if (morphFallbackTimer !== null) window.clearTimeout(morphFallbackTimer);
morphFallbackTimer = window.setTimeout(() => {
morphFallbackTimer = null;
if (rafHandle === null) return; // 已经收敛
if (rafHandle !== null) window.cancelAnimationFrame(rafHandle);
rafHandle = null;
const size = targetSize();
springs.width.snap(size.width);
springs.height.snap(size.height);
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
refreshInteractive();
}, MORPH_FALLBACK_MS);
const frame = (now: number): void => {
const delta = now - lastFrameAt;
lastFrameAt = now;
@@ -270,6 +292,10 @@ function startFrameLoop(): void {
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
if (morphFallbackTimer !== null) {
window.clearTimeout(morphFallbackTimer);
morphFallbackTimer = null;
}
refreshInteractive();
rafHandle = null;
};
@@ -457,6 +483,10 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
onMounted(async () => {
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
// 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口"
// 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。
bridge?.alive?.();
window.setInterval(() => bridge?.alive?.(), 5000);
bridge?.onState((next) => {
state.value = next;
syncTargets();
+194 -5
View File
@@ -143,6 +143,12 @@ class IslandController {
private explicitShowId: string | null = null;
/** 预览示例岛的自动收尾定时器 */
private previewTimer: ReturnType<typeof setTimeout> | null = null;
/** 渲染进程最近一次心跳/存活信号(用于发现"卡死的幽灵窗口") */
private lastAliveAt = 0;
/** 正在重建窗口(避免并发重建) */
private recovering = false;
/** 最近的窗口重建时刻(限流用) */
private recoverAt: number[] = [];
private state: IslandState = {
mode: 'hidden',
@@ -252,6 +258,25 @@ class IslandController {
});
this.win = win;
this.lastAliveAt = Date.now();
/**
* 渲染进程没了/卡了时的自愈:隐藏死窗口(避免留下"幽灵胶囊")并重建窗口。
* 三种触发:进程崩溃、系统判定无响应、心跳超时(见 checkRendererAlive)。
*/
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(
`灵动岛渲染进程已退出(reason=${details.reason} exitCode=${details.exitCode}),开始重建窗口`,
);
this.recoverWindow('渲染进程退出');
});
win.webContents.on('unresponsive', () => {
logger.warn('灵动岛渲染进程无响应(等待心跳恢复,超时将重建窗口)');
});
win.webContents.on('responsive', () => {
logger.info('灵动岛渲染进程已恢复响应');
this.lastAliveAt = Date.now();
});
// 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题
win.webContents.on('console-message', (...args) => {
@@ -315,6 +340,37 @@ class IslandController {
return this.isImmediate(notification) || notification.kind === 'call';
}
/**
* 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new`
* (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、
* 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 ——
* 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图:
* 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。
*
* 合并规则:类型取更特殊的(call 优先),优先级取更高的(URGENT > HIGH > NORMAL)。
*/
private mergeNotification(
existing: IslandNotification | null,
incoming: IslandNotification,
): IslandNotification {
if (!existing || existing.id !== incoming.id) return incoming;
const kind = existing.kind === 'call' || incoming.kind === 'call' ? 'call' : incoming.kind;
const rank: Record<IslandNotification['priority'], number> = {
LOW: 0,
NORMAL: 1,
HIGH: 2,
URGENT: 3,
};
const priority = rank[incoming.priority] >= rank[existing.priority] ? incoming.priority : existing.priority;
return { ...incoming, kind, priority };
}
/** 找到同 id 的已知消息(正在展示的或队列里的) */
private findKnownNotification(id: string): IslandNotification | null {
if (this.state.active?.id === id) return this.state.active;
return this.state.queued.find((item) => item.id === id) ?? null;
}
/**
* 收到一条消息。
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
@@ -322,7 +378,9 @@ class IslandController {
* - 上课中:普通通知进队列并保持隐藏;**叫人**仍以胶囊形式可见(可点开、不自动展开)
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
*/
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void {
// 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义
const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw);
const inClass = context.inClass ?? this.state.inClass;
this.state.inClass = inClass;
if (context.currentPeriodEnd !== undefined)
@@ -769,10 +827,14 @@ class IslandController {
return;
}
if (!this.win.isVisible()) {
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
this.cancelFade();
if (this.win.getOpacity() !== this.appearance.opacity) {
this.win.setOpacity(this.appearance.opacity);
}
if (!this.win.isVisible()) {
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
this.win.showInactive();
}
// 命中一律按"真实光标是否落在岛上"决定:**绝不**整块固定包围盒吃鼠标,
@@ -1011,9 +1073,43 @@ class IslandController {
return { x: point.x - bounds.x, y: point.y - bounds.y };
}
/**
* 岛体矩形:优先用渲染进程上报的真实几何;渲染进程还没上报时用**主进程按当前形态算出的
* 目标矩形**兜底 —— 否则会出现"岛明明可见、窗口却一直不接收鼠标(点了没反应)"这种死局。
*/
private effectiveHitRect(): { x: number; y: number; width: number; height: number } | null {
return this.hitRect ?? this.expectedHitRect();
}
/** 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底 */
private expectedHitRect(): { x: number; y: number; width: number; height: number } {
let size = SIZES.pill;
if (this.state.mode === 'expanded') {
size =
this.state.active?.kind === 'call'
? CALL_SIZE
: this.state.active?.priority === 'URGENT'
? URGENT_SIZE
: SIZES.expanded;
} else if (this.state.mode === 'hidden' && this.appearance.idleSliver) {
size = SLIVER_SIZE;
}
const pad = ISLAND_SHADOW_PAD;
const position = this.appearance.position;
const width = Math.round(BBOX_SIZE.width);
const height = Math.round(BBOX_SIZE.height);
const x = position.endsWith('left')
? pad
: position.endsWith('right')
? width - pad - size.width
: Math.round((width - size.width) / 2);
const y = position.startsWith('bottom') ? height - pad - size.height : pad;
return { x, y, width: size.width, height: size.height };
}
/** 本地坐标是否落在岛体矩形内(含 2px 容差) */
private isInsideHitRect(local: { x: number; y: number } | null): boolean {
const rect = this.hitRect;
const rect = this.effectiveHitRect();
if (!local || !rect) return false;
const margin = 2;
return (
@@ -1056,7 +1152,10 @@ class IslandController {
if (this.hitTimer) return;
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
this.hitTimer = setInterval(() => this.syncHitFromCursor(), 60);
this.hitTimer = setInterval(() => {
this.syncHitFromCursor();
this.checkRendererAlive();
}, 60);
// 定时器不应拖住进程退出
this.hitTimer.unref?.();
}
@@ -1067,6 +1166,79 @@ class IslandController {
this.hitTimer = null;
}
/** 渲染进程心跳(渲染进程每隔几秒上报一次) */
noteRendererAlive(): void {
this.lastAliveAt = Date.now();
}
/**
* 心跳检查:岛可见(非隐藏态)却长时间收不到渲染进程心跳 → 判定渲染进程卡死,
* 隐藏死窗口并重建。这是"幽灵胶囊(看着在、点不动)"的兜底自愈。
*/
private checkRendererAlive(): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return;
if (this.state.mode === 'hidden' && !this.appearance.idleSliver) return;
if (Date.now() - this.lastAliveAt <= RENDERER_ALIVE_TIMEOUT_MS) return;
logger.error(
`灵动岛渲染进程超过 ${Math.round(RENDERER_ALIVE_TIMEOUT_MS / 1000)}s 没有心跳(疑似卡死),重建窗口`,
);
this.lastAliveAt = Date.now(); // 先重置,避免 60ms 轮询里重复触发
this.recoverWindow('心跳超时');
}
/**
* 重建灵动岛窗口(渲染进程崩溃/卡死后的自愈)。
*
* 关键:先把**窗口隐藏**,否则 Windows 会保留最后一帧 —— 用户看到"岛还挂在屏幕上,
* 但怎么点都没反应"。随后销毁旧窗口并重新 init(状态与外观会重新下发)。
* 限流:10 分钟内最多重建 RECOVER_MAX_TIMES 次,超出则暂停并隐藏,避免无限重建。
*/
private recoverWindow(reason: string): void {
if (this.recovering) return;
const now = Date.now();
this.recoverAt = this.recoverAt.filter((at) => now - at < RECOVER_WINDOW_MS);
if (this.recoverAt.length >= RECOVER_MAX_TIMES) {
logger.error(`灵动岛已连续重建 ${RECOVER_MAX_TIMES} 次(${reason}),暂停自动恢复并隐藏窗口`);
this.hideImmediately();
return;
}
this.recoverAt.push(now);
this.recovering = true;
// 1) 立刻隐藏死窗口:不然屏幕上会留一帧"点不动的幽灵胶囊"
try {
this.setInteractive(false, '重建窗口');
this.win?.hide();
} catch {
// 忽略:窗口可能已销毁
}
// 2) 销毁旧窗口并重建
const old = this.win;
this.win = null;
this.ready = false;
try {
if (old && !old.isDestroyed()) old.destroy();
} catch {
// 忽略
}
void this.init(this.rendererUrl)
.then(() => {
// 新窗口是 show:false 创建的,必须按当前状态同步一次可见性/命中
this.syncWindow();
logger.info(`灵动岛窗口已重建(${reason}),状态与外观已重新下发`);
})
.catch((error) => {
logger.error(`灵动岛窗口重建失败:${(error as Error).message}`);
})
.finally(() => {
this.recovering = false;
});
}
/** 当前是否在重建窗口(供冒烟验证) */
isRecovering(): boolean {
return this.recovering;
}
/** 当前是否接收鼠标(供冒烟验证) */
/** 诊断/冒烟用:开关"失焦自动收起" */
setBlurCollapseEnabled(enabled: boolean): void {
@@ -1179,6 +1351,18 @@ const BLUR_GRACE_MS = 500;
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
const PREVIEW_DURATION_MS = 30_000;
/**
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
* 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后
* 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。
*/
const RENDERER_ALIVE_INTERVAL_MS = 5_000;
const RENDERER_ALIVE_TIMEOUT_MS = 20_000;
/** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */
const RECOVER_MAX_TIMES = 3;
const RECOVER_WINDOW_MS = 10 * 60_000;
interface Bounds {
x: number;
y: number;
@@ -1211,6 +1395,11 @@ export function registerIslandIpc(): void {
island.setClassState(payload);
});
// 渲染进程心跳:用于发现"幽灵窗口"(渲染进程卡死但窗口还留在屏幕上,点了没反应)
ipcMain.on('island:alive', () => {
island.noteRendererAlive();
});
ipcMain.handle('island:get-state', () => island.getState());
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
+95 -4
View File
@@ -2077,13 +2077,35 @@ async function runIslandChecks(
// 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开
const sliver = getSliverSize();
/**
* 等岛体几何收敛到目标尺寸(不等固定时长)。
* 原因:Windows 有时会把置顶透明小窗判定为"被遮挡/后台",Chromium 会把 rAF 压到 1 帧/秒
* (实测打包版细缝态出现过 1.1fps),此时形变会明显变慢;渲染进程 1.2s 后会自动 snap 到目标,
* 所以这里轮询等待即可,固定 sleep 会误判成"细缝尺寸不对"。
*/
const waitIslandSize = async (
target: { width: number; height: number },
timeoutMs = 4000,
): Promise<{ width: number; height: number } | null> => {
const deadline = Date.now() + timeoutMs;
let last: { width: number; height: number } | null = null;
while (Date.now() < deadline) {
last = (await readIslandGeometry())?.island ?? null;
if (last && Math.abs(last.width - target.width) <= 1 && Math.abs(last.height - target.height) <= 1) {
return last;
}
await sleep(120);
}
return last;
};
island.setAppearance({ ...appearanceBefore, idleSliver: true });
await drainIsland();
await sleep(420);
const sliverBounds = (await readIslandGeometry())?.island ?? null;
const sliverBounds = await waitIslandSize(sliver);
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
await sleep(700);
const afterSliverNotification = (await readIslandGeometry())?.island ?? null;
const afterSliverNotification = await waitIslandSize({
width: island.getAppearance().width,
height: island.getAppearance().height,
});
record(
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 &&
@@ -2249,6 +2271,75 @@ async function runIslandChecks(
island.setAppearance(appearanceBefore);
await sleep(300);
// 4.12) 同一条消息的"双广播"不能把"叫人"降级成普通通知。
// 服务端 createCall 既发 notification:new(班级房间)又发 call:new(user 房间),
// 两条 DTO 同 id;后到的那条若直接覆盖,胶囊会显示成"新消息"(用户实测截图正是如此),
// 紧急叫人还会被降级成"课中只排队、不立即展开"。
const mergeCallId = 'smoke-call-merge';
island.pushNotification(
{ ...makeNotification(mergeCallId, 'HIGH', '请 王小明 同学找 张老师'), kind: 'call' },
{ inClass: false },
);
await sleep(250);
island.pushNotification(makeNotification(mergeCallId, 'HIGH', '请 王小明 同学找 张老师'), {
inClass: false,
});
await sleep(450);
const mergedCall = island.getState();
record(
'双广播(叫人 + 通知)不降级:同 id 合并后仍按"叫人"处理',
mergedCall.active?.id === mergeCallId && mergedCall.active?.kind === 'call' && mergedCall.mode === 'pill',
`mode=${mergedCall.mode} kind=${mergedCall.active?.kind ?? '-'} priority=${mergedCall.active?.priority ?? '-'}`,
);
const mergeUrgentId = 'smoke-call-merge-urgent';
island.pushNotification(makeNotification(mergeUrgentId, 'NORMAL', '请 王小明 同学找 张老师'), {
inClass: true,
});
await sleep(250);
island.pushNotification(
{ ...makeNotification(mergeUrgentId, 'URGENT', '请 王小明 同学找 张老师'), kind: 'call' },
{ inClass: true },
);
await sleep(450);
const mergedUrgent = island.getState();
record(
'紧急叫人双广播:合并后仍 URGENT 立即展开(课中不被降级为暂存)',
mergedUrgent.active?.id === mergeUrgentId &&
mergedUrgent.mode === 'expanded' &&
mergedUrgent.active?.priority === 'URGENT',
`mode=${mergedUrgent.mode} priority=${mergedUrgent.active?.priority ?? '-'} kind=${mergedUrgent.active?.kind ?? '-'}`,
);
island.setClassState({ inClass: false, currentPeriodEnd: null });
island.handleAction({ action: 'dismiss' });
await sleep(300);
// 4.13) 渲染进程崩溃自愈:岛窗口绝不能变成"屏幕上一帧点不动的幽灵胶囊"。
// 真实现象(本机实测):崩掉岛渲染进程后,Windows 保留最后一帧 —— 胶囊看着还在,
// 窗口也还在,但点击全部丢失(主进程日志里再无"收到用户动作")。
// 这里断言主进程会隐藏死窗口并重建,重建后状态回来了、也能重新命中。
island.pushNotification(makeNotification('smoke-renderer-recover', 'NORMAL', '渲染进程自愈'), {
inClass: false,
});
await sleep(500);
const windowBeforeCrash = island.getWindow();
islandWindow?.webContents.forcefullyCrashRenderer();
await sleep(3000);
const windowAfterCrash = island.getWindow();
const recovered = island.isReady() && Boolean(windowAfterCrash) && windowAfterCrash !== windowBeforeCrash;
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(250);
const recoveredInteractive = island.getInteractive();
const recoveredState = island.getState();
record(
'渲染进程崩溃后自愈(隐藏死窗口 + 重建 + 恢复命中,不再留"点不动的幽灵胶囊")',
recovered && recoveredInteractive && recoveredState.mode === 'pill',
`窗口已重建=${windowAfterCrash !== windowBeforeCrash} ready=${island.isReady()} ` +
`mode=${recoveredState.mode} 命中=${recoveredInteractive}`,
);
island.setHitTestCursor(null);
island.handleAction({ action: 'dismiss' });
await sleep(300);
// 收尾:隐藏灵动岛,避免影响后续用例
island.handleAction({ action: 'dismiss' });
await sleep(300);
@@ -16,6 +16,8 @@ export interface IslandBridge {
setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
alive(): void;
}
const bridge: IslandBridge = {
@@ -36,6 +38,9 @@ const bridge: IslandBridge = {
setHitRect: (rect) => {
ipcRenderer.send('island:set-hit-rect', rect);
},
alive: () => {
ipcRenderer.send('island:alive');
},
};
contextBridge.exposeInMainWorld('island', bridge);
+3 -1
View File
@@ -573,7 +573,9 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
ok,
detail:
`卡片=${dom.count} 进行中=${dom.hasCurrent} 进度条=${dom.hasProgress} 倒计时=${countdown} ` +
`已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"`,
`已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"` +
// 失败时能看出"到底渲染了哪几节、各自什么状态",否则只看到"已结束=false"无法定位
` 卡片状态=[${dom.tags.map((tag) => `${tag.state}:${tag.text.slice(0, 24)}`).join(' | ')}]`,
};
} catch (error) {
return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` };
+2
View File
@@ -97,6 +97,8 @@ export interface IslandRendererBridge {
setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
alive(): void;
}
declare global {