fix: sync wait overlay with runtime

This commit is contained in:
JOJO 2025-12-14 23:28:22 +08:00
parent d5e6c9c077
commit 9750b0b8f1
4 changed files with 389 additions and 33 deletions

View File

@ -158,9 +158,28 @@
</div>
</div>
<div class="window wait-window" ref="waitWindow">
<div class="window-header">
<span class="traffic-dot red"></span>
<span class="traffic-dot yellow"></span>
<span class="traffic-dot green"></span>
<span class="window-title">等待</span>
</div>
<div class="wait-body">
<div class="flip-clock" ref="waitDisplay">
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div>
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div>
<div class="flip-separator">:</div>
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div>
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div>
</div>
</div>
</div>
<div class="context-menu" ref="desktopMenu">
<button data-action="file">新建文件</button>
<button data-action="folder">新建文件夹</button>
<button data-action="wait">等待</button>
</div>
<div class="context-menu" ref="folderMenu">
<button data-action="file">新建文件</button>
@ -169,6 +188,7 @@
<div class="context-menu" ref="fileMenu">
<button data-action="read">阅读文件</button>
<button data-action="edit">编辑文件</button>
<button data-action="delete">删除文件</button>
</div>
<div class="context-menu" ref="focusMenu">
<button data-action="focus">聚焦文件</button>
@ -191,6 +211,14 @@
<div class="mouse-pointer" ref="mousePointer">
<img :src="mouseIcon" alt="pointer" />
</div>
<div class="wait-overlay" ref="waitOverlay">
<div class="z z-1">Z</div>
<div class="z z-2">Z</div>
<div class="z z-3">Z</div>
<div class="z z-4">Z</div>
<div class="wait-countdown" ref="waitCountdown">5s</div>
</div>
</div>
</div>
<div class="monitor-stand"></div>
@ -253,6 +281,10 @@ const memoryTime = ref<HTMLElement | null>(null);
const todoWindow = ref<HTMLElement | null>(null);
const todoSummary = ref<HTMLElement | null>(null);
const todoList = ref<HTMLElement | null>(null);
const waitWindow = ref<HTMLElement | null>(null);
const waitDisplay = ref<HTMLElement | null>(null);
const waitOverlay = ref<HTMLElement | null>(null);
const waitCountdown = ref<HTMLElement | null>(null);
const desktopMenu = ref<HTMLElement | null>(null);
const folderMenu = ref<HTMLElement | null>(null);
const fileMenu = ref<HTMLElement | null>(null);
@ -334,6 +366,10 @@ const mountDirector = async () => {
todoWindow: todoWindow.value!,
todoSummary: todoSummary.value!,
todoList: todoList.value!,
waitWindow: waitWindow.value!,
waitDisplay: waitDisplay.value!,
waitOverlay: waitOverlay.value!,
waitCountdown: waitCountdown.value!,
desktopMenu: desktopMenu.value!,
folderMenu: folderMenu.value!,
fileMenu: fileMenu.value!,

View File

@ -65,6 +65,10 @@ export interface MonitorElements {
todoWindow: HTMLElement;
todoSummary: HTMLElement;
todoList: HTMLElement;
waitWindow: HTMLElement;
waitDisplay: HTMLElement;
waitOverlay: HTMLElement;
waitCountdown: HTMLElement;
desktopMenu: HTMLElement;
folderMenu: HTMLElement;
fileMenu: HTMLElement;
@ -237,6 +241,9 @@ export class MonitorDirector implements MonitorDriver {
private waitingBubbleTimer: number | null = null;
private waitingBubbleBase: string | null = null;
private progressBubbleActive = false;
private secondaryMenu: HTMLElement | null = null;
private waitDigits: HTMLElement[] = [];
private waitOverlayTimer: number | null = null;
// 当实际执行进度快于动画播放时,用于压制“正在 xxx”提示
private playbackLagging = false;
// 记录最近一次 Python 执行的ID用于丢弃过期动画/结果
@ -2087,7 +2094,8 @@ export class MonitorDirector implements MonitorDriver {
this.elements.terminalWindow,
this.elements.readerWindow,
this.elements.memoryWindow,
this.elements.todoWindow
this.elements.todoWindow,
this.elements.waitWindow
].forEach(win => this.closeWindow(win, { animate: false }));
this.clearExtractionWindows();
this.clearTerminalSessions();
@ -2106,10 +2114,12 @@ export class MonitorDirector implements MonitorDriver {
this.windowAnchors.set(this.elements.readerWindow, { x: 0.42, y: 0.05 });
this.windowAnchors.set(this.elements.memoryWindow, { x: 0.28, y: 0.32 });
this.windowAnchors.set(this.elements.todoWindow, { x: 0.5, y: 0.32 });
this.windowAnchors.set(this.elements.waitWindow, { x: 0.74, y: 0.24 });
}
private layoutFloatingWindows() {
this.windowAnchors.forEach((anchor, el) => this.positionWindow(el, anchor));
this.hideWaitOverlay();
}
private positionWindow(el: HTMLElement, anchor: { x: number; y: number }) {
@ -2380,6 +2390,25 @@ export class MonitorDirector implements MonitorDriver {
await sleep(300);
return;
}
const openDeleteMenu = async (targetEl: HTMLElement | null, menuType: ContextMenuType = 'file') => {
if (!targetEl) {
return false;
}
await this.movePointerToElement(targetEl, { duration: 600 });
await this.click({ right: true });
this.showContextMenu('file');
await sleep(160);
const highlighted = await this.highlightMenu('file', 'delete');
if (!highlighted) {
const btn = this.elements.fileMenu.querySelector<HTMLButtonElement>('button[data-action="delete"]');
if (btn) {
await this.movePointerToElement(btn, { duration: 320 });
}
}
await this.click();
this.hideContextMenus();
return true;
};
if (segments.length) {
const parentKey = await this.openFolderChain(segments);
if (parentKey) {
@ -2390,20 +2419,18 @@ export class MonitorDirector implements MonitorDriver {
await sleep(40);
const entryPath = this.composePath([parentKey, name]);
const entryEl = this.findFolderEntryElement(entryPath);
if (entryEl) {
await this.movePointerToElement(entryEl, { duration: 600 });
entryEl.classList.add('removing');
setTimeout(() => {
this.removeFolderEntry(parentKey, name);
}, 260);
} else {
const menuShown = await openDeleteMenu(entryEl, 'file');
if (!menuShown) {
this.removeFolderEntry(parentKey, name);
return;
}
entryEl?.classList.add('removing');
setTimeout(() => this.removeFolderEntry(parentKey, name), 260);
}
} else {
const icon = this.fileIcons.get(name) || this.folderIcons.get(name);
if (icon) {
await this.movePointerToElement(icon, { duration: 500 });
await openDeleteMenu(icon, 'file');
icon.classList.add('removing');
setTimeout(() => icon.remove(), 320);
this.fileIcons.delete(name);
@ -2420,6 +2447,28 @@ export class MonitorDirector implements MonitorDriver {
await sleep(400);
};
this.sceneHandlers.wait = async (payload, runtime) => {
this.applySceneStatus(runtime, 'wait', '正在等待');
const duration =
Number(payload?.arguments?.duration || payload?.arguments?.seconds || payload?.seconds || payload?.duration) || 5;
await this.movePointerToDesktop();
await this.click({ right: true });
this.showContextMenu('desktop');
await sleep(200);
const highlighted = await this.highlightMenu('desktop', 'wait');
if (!highlighted) {
const btn = this.elements.desktopMenu.querySelector<HTMLButtonElement>('button[data-action="wait"]');
if (btn) {
await this.movePointerToElement(btn, { duration: 320 });
}
}
await this.click();
this.hideContextMenus();
await sleep(140); // 等菜单完全收起后再出现等待提示
const waitPromise = runtime.waitForResult(payload.executionId || payload.id).catch(() => null);
await this.playWaitCountdown(duration, waitPromise);
};
this.sceneHandlers.appendFile = async (payload, runtime) => {
this.applySceneStatus(runtime, 'appendFile', '正在编辑');
const rawPath = payload?.arguments?.path || payload?.argumentSnapshot?.path || 'file.txt';
@ -2956,20 +3005,29 @@ export class MonitorDirector implements MonitorDriver {
};
this.sceneHandlers.terminalSleep = async (payload, runtime) => {
this.applySceneStatus(runtime, 'terminalSleep', '正在关闭终端');
const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: false, activate: false });
terminalMenuDebug('scene:terminalSleep:start', { sessionId });
await this.openTerminalContextMenu(sessionId);
terminalMenuDebug('scene:terminalSleep:menu-opened', { sessionId });
await this.chooseTerminalMenuAction('close');
this.closeTerminalSession(sessionId);
// 如果已无会话,收起终端窗口以呈现“窗口被关闭”效果
if (!this.terminalSessions.size) {
this.closeWindow(this.elements.terminalWindow, { animate: true });
this.applySceneStatus(runtime, 'terminalSleep', '正在等待');
const duration =
Number(payload?.arguments?.duration || payload?.arguments?.seconds || payload?.seconds || payload?.duration) || 5;
await this.movePointerToDesktop();
await this.click({ right: true });
this.showContextMenu('desktop');
await sleep(200);
const highlighted = await this.highlightMenu('desktop', 'wait');
if (!highlighted) {
const btn = this.elements.desktopMenu.querySelector<HTMLButtonElement>('button[data-action="wait"]');
if (btn) {
await this.movePointerToElement(btn, { duration: 320 });
}
await sleep(320);
}
await this.click();
this.hideContextMenus();
await sleep(140); // 等菜单完全收起后再出现等待提示
const waitPromise = runtime.waitForResult(payload.executionId || payload.id).catch(() => null);
await this.playWaitCountdown(duration, waitPromise);
};
this.sceneHandlers.sleep = this.sceneHandlers.wait;
this.sceneHandlers.webSave = async (_payload, runtime) => {
this.applySceneStatus(runtime, 'webSave', '正在保存网页');
const targetUrl =
@ -3521,9 +3579,57 @@ export class MonitorDirector implements MonitorDriver {
menu.classList.remove('visible');
menu.querySelectorAll('button').forEach(btn => btn.classList.remove('active'));
});
this.hideSecondaryMenu();
this.hideWaitOverlay();
this.terminalContextSessionId = null;
}
private hideSecondaryMenu() {
if (this.secondaryMenu && this.secondaryMenu.parentElement) {
this.secondaryMenu.parentElement.removeChild(this.secondaryMenu);
}
this.secondaryMenu = null;
}
private showSecondaryMenu(anchorMenu: HTMLElement | null, items: Array<{ label: string; action: string }>) {
this.hideSecondaryMenu();
if (!anchorMenu || !items.length) {
return null;
}
const menu = document.createElement('div');
menu.className = 'context-menu secondary-menu visible';
items.forEach(item => {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.dataset.action = item.action;
menu.appendChild(btn);
});
this.elements.screen.appendChild(menu);
const rect = anchorMenu.getBoundingClientRect();
const left = rect.right - this.screenRect.left + 10;
const top = rect.top - this.screenRect.top + 4;
const { x, y } = this.clampToScreen(left, top, menu.offsetWidth || 180, menu.offsetHeight || 60);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
this.secondaryMenu = menu;
return menu;
}
private async chooseSecondaryMenuAction(action: string) {
const btn = this.secondaryMenu?.querySelector<HTMLButtonElement>(`button[data-action="${action}"]`) || null;
if (!btn) {
return false;
}
await this.movePointerToElement(btn, { duration: 320 });
btn.classList.add('active');
await sleep(200);
btn.classList.remove('active');
await this.click();
await sleep(140);
this.hideSecondaryMenu();
return true;
}
private async highlightMenu(type: ContextMenuType, action: string): Promise<boolean> {
const map: Record<ContextMenuType, HTMLElement> = {
desktop: this.elements.desktopMenu,
@ -3620,6 +3726,59 @@ export class MonitorDirector implements MonitorDriver {
return menu.classList.contains('visible');
}
private hideWaitOverlay() {
if (!this.elements.waitOverlay) return;
this.elements.waitOverlay.classList.remove('active');
if (this.waitOverlayTimer) {
clearInterval(this.waitOverlayTimer);
this.waitOverlayTimer = null;
}
}
private formatWaitText(seconds: number) {
if (seconds >= 60) {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
return `${seconds}s`;
}
private async playWaitCountdown(durationSeconds = 5, until?: Promise<any>) {
const total = Math.max(1, Math.round(durationSeconds));
if (!this.elements.waitOverlay || !this.elements.waitCountdown) {
return;
}
const tip = this.getPointerTip();
this.elements.waitOverlay.style.left = `${tip.x + 16}px`;
this.elements.waitOverlay.style.top = `${tip.y - 28}px`;
const start = performance.now();
this.elements.waitCountdown.textContent = this.formatWaitText(total);
this.elements.waitOverlay.classList.add('active');
if (this.waitOverlayTimer) {
clearInterval(this.waitOverlayTimer);
this.waitOverlayTimer = null;
}
const update = () => {
const elapsed = (performance.now() - start) / 1000;
const remain = Math.max(0, total - elapsed);
if (this.elements.waitCountdown) {
this.elements.waitCountdown.textContent = this.formatWaitText(Math.ceil(remain));
}
};
this.waitOverlayTimer = window.setInterval(update, 200);
const done = until || Promise.resolve();
try {
await done;
} catch (_err) {
// 忽略错误,仍然结束动画
}
update();
this.hideWaitOverlay();
}
/**
*
*/
@ -5134,25 +5293,49 @@ export class MonitorDirector implements MonitorDriver {
await sleep(180);
}
private async scrollTodoItemIntoView(card: HTMLElement | null) {
private async scrollTodoItemIntoView(card: HTMLElement | null, options: { waitMs?: number } = {}) {
if (!card || !this.elements.todoList) return;
const body = this.elements.todoList;
const cardTop = card.offsetTop;
const cardBottom = cardTop + card.offsetHeight;
const margin = 8;
const wait = typeof options.waitMs === 'number' ? options.waitMs : 120;
const measure = () => {
const bodyRect = body.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const relTop = cardRect.top - bodyRect.top + body.scrollTop;
const relBottom = relTop + cardRect.height;
const viewTop = body.scrollTop;
const viewBottom = viewTop + body.clientHeight;
return { relTop, relBottom, viewTop, viewBottom };
};
// 如果完整可见,直接返回
if (cardTop >= viewTop && cardBottom <= viewBottom) {
let { relTop, relBottom, viewTop, viewBottom } = measure();
// 已完整可见(含少量缓冲),直接返回
if (relTop >= viewTop + margin && relBottom <= viewBottom - margin) {
return;
}
// 让整个卡片进入视口:若底部被遮挡,滚到卡片底部露出;否则滚到卡片顶部
const targetTop =
cardBottom > viewBottom ? cardBottom - body.clientHeight + 4 : Math.max(0, cardTop - 4);
// 计算需要滚动的位置(基于相对坐标,避免 offsetParent 不一致的问题)
const needsScrollDown = relBottom > viewBottom - margin;
const targetTop = needsScrollDown ? relBottom - body.clientHeight + margin : Math.max(0, relTop - margin);
const clampedTop = Math.max(0, targetTop);
body.scrollTop = Math.max(0, targetTop);
body.scrollTo({ top: clampedTop, behavior: 'smooth' });
await this.waitForScrollSettled(body, clampedTop);
if (wait > 0) {
await sleep(wait);
}
// 二次校验,确保整块已进入可视区域
({ relTop, relBottom, viewTop, viewBottom } = measure());
if (relTop < viewTop + margin) {
body.scrollTop = Math.max(0, relTop - margin);
await sleep(30);
} else if (relBottom > viewBottom - margin) {
body.scrollTop = relBottom - body.clientHeight + margin;
await sleep(30);
}
}
private async toggleTodoItem(text?: string | null, done?: boolean, index?: number | null) {

View File

@ -21,10 +21,12 @@ export const SCENE_PROGRESS_LABELS: Record<string, string> = {
todoFinish: '正在完成任务',
todoFinishConfirm: '正在确认任务',
todoDelete: '正在移除任务',
wait: '正在等待',
sleep: '正在等待',
createFolder: '正在创建文件夹',
renameFile: '正在重命名',
terminalReset: '正在重置终端',
terminalSleep: '正在暂停终端',
terminalSleep: '准备等待',
terminalRun: '正在执行',
ocr: '正在提取',
memory: '正在同步记忆',

View File

@ -1253,6 +1253,87 @@
border: 1px solid rgba(39, 201, 119, 0.4);
}
.virtual-monitor-surface .wait-window {
width: 260px;
height: 180px;
top: 120px;
left: 820px;
display: flex;
flex-direction: column;
}
.virtual-monitor-surface .wait-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 20% 20%, rgba(118, 148, 255, 0.18), transparent 45%), radial-gradient(circle at 80% 30%, rgba(92, 209, 183, 0.16), transparent 40%), rgba(248, 251, 255, 0.9);
border-radius: 0 0 14px 14px;
padding: 18px;
}
.virtual-monitor-surface .flip-clock {
display: flex;
align-items: center;
gap: 8px;
perspective: 900px;
font-variant-numeric: tabular-nums;
}
.virtual-monitor-surface .flip-separator {
font-size: 26px;
color: #2d3748;
margin: 0 4px;
font-weight: 600;
}
.virtual-monitor-surface .flip-digit {
position: relative;
width: 44px;
height: 56px;
border-radius: 10px;
background: linear-gradient(180deg, #101827, #0b1222);
color: #f5fbff;
overflow: hidden;
box-shadow: 0 12px 24px rgba(14, 32, 68, 0.35);
transition: transform 0.2s ease;
}
.virtual-monitor-surface .flip-digit .top,
.virtual-monitor-surface .flip-digit .bottom {
position: absolute;
left: 0;
right: 0;
height: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
backface-visibility: hidden;
}
.virtual-monitor-surface .flip-digit .top {
top: 0;
transform-origin: bottom;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0));
}
.virtual-monitor-surface .flip-digit .bottom {
bottom: 0;
transform-origin: top;
}
.virtual-monitor-surface .flip-digit.flipping {
animation: flipCard 0.38s ease forwards;
}
@keyframes flipCard {
0% { transform: rotateX(0deg); }
50% { transform: rotateX(-82deg); }
100% { transform: rotateX(0deg); }
}
.virtual-monitor-surface .context-menu {
position: absolute;
background: rgba(9, 16, 32, 0.96);
@ -1278,6 +1359,11 @@
transform: translateY(0);
}
.virtual-monitor-surface .context-menu.secondary-menu {
z-index: 45;
min-width: 130px;
}
.virtual-monitor-surface .context-menu button {
border: none;
background: transparent;
@ -1317,6 +1403,55 @@
--arrow-size: 18px;
}
.virtual-monitor-surface .wait-overlay {
position: absolute;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
z-index: 48;
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.25));
}
.virtual-monitor-surface .wait-overlay.active {
opacity: 1;
}
.virtual-monitor-surface .wait-overlay .z {
position: absolute;
font-size: 28px;
font-weight: 700;
color: #111;
opacity: 0;
animation: swayUpToRight 2s ease-out infinite;
}
.virtual-monitor-surface .wait-overlay.active .z {
opacity: 1;
}
.virtual-monitor-surface .wait-overlay .z-2 { animation-delay: 0.25s; }
.virtual-monitor-surface .wait-overlay .z-3 { animation-delay: 0.5s; }
.virtual-monitor-surface .wait-overlay .z-4 { animation-delay: 0.75s; }
.virtual-monitor-surface .wait-overlay .wait-countdown {
position: absolute;
left: 34px;
top: 8px;
padding: 0;
color: #111;
background: transparent;
border: none;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.5px;
box-shadow: none;
}
@keyframes swayUpToRight {
0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
100% { transform: translate(80px, -100px) rotate(30deg); opacity: 0; }
}
.virtual-monitor-surface .speech-bubble.visible {
opacity: 1;
transform: scale(1);