+
+
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 4e66ba5..8da1777 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -126,6 +126,52 @@
return [sync, reset];
}
+ /** 悬停弹出:参数表单分组概要(数据与 configPanel 同源:configGroups / groupLabels) */
+ function buildGroupPop(task, schema) {
+ const groups = schema?.configGroups && typeof schema.configGroups === 'object'
+ ? Object.entries(schema.configGroups) : [];
+ if (!groups.length) return null;
+ const pop = document.createElement('div');
+ pop.className = 'pop';
+ const title = document.createElement('div');
+ title.className = 'pop-title';
+ title.textContent = schema?.displayName || task.displayName || '';
+ const sub = document.createElement('span');
+ sub.className = 'pop-sub';
+ sub.textContent = t('depsPopSub', { count: groups.length });
+ title.appendChild(sub);
+ pop.appendChild(title);
+ for (const [key, fieldKeys] of groups) {
+ const list = Array.isArray(fieldKeys) ? fieldKeys : [];
+ const grp = document.createElement('div');
+ grp.className = 'grp';
+ const head = document.createElement('div');
+ head.className = 'grp-head';
+ const name = document.createElement('b');
+ name.textContent = schema.groupLabels?.[key] || key;
+ const cnt = document.createElement('span');
+ cnt.className = 'cnt';
+ cnt.textContent = t('itemsCount', { count: list.length });
+ head.append(name, cnt);
+ const body = document.createElement('div');
+ body.className = 'grp-body';
+ for (const fieldKey of list.slice(0, 3)) {
+ const it = document.createElement('div');
+ it.className = 'it';
+ const label = document.createElement('span');
+ label.textContent = fieldKey;
+ const st = document.createElement('span');
+ st.className = 'st ok';
+ st.textContent = t('depsPopTaken');
+ it.append(label, st);
+ body.appendChild(it);
+ }
+ grp.append(head, body);
+ pop.appendChild(grp);
+ }
+ return pop;
+ }
+
function buildTaskCard(task) {
const key = taskKey(task);
const schema = state.schemas[key];
@@ -143,6 +189,10 @@
identity.className = 'task-card__identity';
const titleRow = document.createElement('div');
titleRow.className = 'task-card__title-row';
+ const dot = document.createElement('span');
+ dot.className = 'rc-dot';
+ dot.setAttribute('aria-hidden', 'true');
+ titleRow.appendChild(dot);
const name = document.createElement('div');
name.className = 'task-card__name';
name.textContent = displayName;
@@ -175,6 +225,11 @@
// 点击「参数」按钮时由 console.js 把这个节点搬运进抽屉显示。
const configPanel = buildConfigPanel(task, schema);
card.append(header, configPanel);
+ const pop = buildGroupPop(task, schema);
+ if (pop) {
+ card.classList.add('task-card--pop');
+ card.appendChild(pop);
+ }
return card;
}
@@ -203,6 +258,7 @@
function updateRunningState() {
for (const card of elements.tasks.querySelectorAll('.task-card')) {
const status = cardState(card);
+ card.dataset.run = status || 'idle';
card.classList.toggle('is-active', status === 'polling' || status === 'running');
const badge = card.querySelector('[data-role="status"]');
if (badge) {
@@ -226,6 +282,39 @@
updateToolbar();
}
+ /** 任务显示名:tasks 列表 → schema → 原始 key 兜底 */
+ function displayNameOf(key) {
+ const task = state.currentTasks.find((item) => taskKey(item) === key);
+ return task?.displayName || state.schemas[key]?.displayName || key;
+ }
+
+ /** 执行队列条:一次性队列可视化(队列芯片随 executor 消息刷新) */
+ function renderQueueStrip() {
+ const strip = document.getElementById('queueStrip');
+ if (!strip) return;
+ const queue = state.executor.onetimeQueue || [];
+ strip.textContent = '';
+ if (!queue.length) {
+ strip.hidden = true;
+ return;
+ }
+ const head = document.createElement('div');
+ head.className = 'rc-queue__head';
+ const title = document.createElement('b');
+ title.textContent = t('queueTitle');
+ head.appendChild(title);
+ const chips = document.createElement('div');
+ chips.className = 'rc-queue__chips';
+ for (const key of queue) {
+ const chip = document.createElement('span');
+ chip.className = 'rc-queue__chip';
+ chip.textContent = displayNameOf(key);
+ chips.appendChild(chip);
+ }
+ strip.append(head, chips);
+ strip.hidden = false;
+ }
+
/** 状态条第二行:当前任务 + 一次性队列(空闲时隐藏) */
function updateExecutorSub() {
const sub = document.getElementById('executorSub');
@@ -233,16 +322,14 @@
const executor = state.executor;
const parts = [];
if (executor.current) {
- const task = state.currentTasks.find((item) => taskKey(item) === executor.current);
- const schema = state.schemas[executor.current];
- const label = task?.displayName || schema?.displayName || executor.current;
- parts.push(t('executorCurrent', { task: label }));
+ parts.push(t('executorCurrent', { task: displayNameOf(executor.current) }));
}
if (executor.onetimeQueue.length) {
parts.push(t('executorQueueCount', { count: executor.onetimeQueue.length }));
}
sub.hidden = !parts.length;
sub.textContent = parts.join(' · ');
+ renderQueueStrip();
}
function updateToolbar() {
diff --git a/src/localization.ts b/src/localization.ts
index 046465e..2fe4206 100644
--- a/src/localization.ts
+++ b/src/localization.ts
@@ -242,6 +242,13 @@ const ZH_CN: WebviewStrings = {
diffTag: '与出厂不同',
runningCount: '● {count} 运行中',
projectStoreTag: '项目自建 store',
+ healthTitle: '配置健康度',
+ healthGroups: '快照组',
+ healthFields: '快照字段',
+ healthOwned: '已定制任务',
+ queueTitle: '执行队列',
+ depsPopSub: '参数分 {count} 组',
+ depsPopTaken: '已接管',
jsonEditBtn: 'JSON 编辑',
openFileBtn: '打开文件',
openDataBtn: '打开数据位置',
@@ -432,6 +439,13 @@ const EN: WebviewStrings = {
diffTag: 'differs from factory',
runningCount: '● {count} running',
projectStoreTag: 'project store',
+ healthTitle: 'Config health',
+ healthGroups: 'Snapshot groups',
+ healthFields: 'Snapshot fields',
+ healthOwned: 'Customized tasks',
+ queueTitle: 'Execution queue',
+ depsPopSub: '{count} config group(s)',
+ depsPopTaken: 'Owned',
jsonEditBtn: 'JSON edit',
openFileBtn: 'Open file',
openDataBtn: 'Open data location',
@@ -483,6 +497,13 @@ const ZH_TW: WebviewStrings = {
diffTag: '與出廠不同',
runningCount: '● {count} 執行中',
projectStoreTag: '專案自建 store',
+ healthTitle: '設定健康度',
+ healthGroups: '快照組',
+ healthFields: '快照欄位',
+ healthOwned: '已客製任務',
+ queueTitle: '執行佇列',
+ depsPopSub: '參數分 {count} 組',
+ depsPopTaken: '已接管',
jsonEditBtn: 'JSON 編輯',
openFileBtn: '開啟檔案',
openDataBtn: '開啟資料位置',
@@ -556,6 +577,13 @@ const JA: WebviewStrings = {
diffTag: '初期値と差異あり',
runningCount: '● {count} 実行中',
projectStoreTag: 'プロジェクト store',
+ healthTitle: '設定ヘルス',
+ healthGroups: 'スナップショット組',
+ healthFields: 'スナップショット項目',
+ healthOwned: 'カスタマイズ済みタスク',
+ queueTitle: '実行キュー',
+ depsPopSub: 'パラメータ {count} グループ',
+ depsPopTaken: '引き継ぎ済み',
jsonEditBtn: 'JSON 編集',
openFileBtn: 'ファイルを開く',
openDataBtn: 'データ場所を開く',
@@ -628,6 +656,13 @@ const KO: WebviewStrings = {
diffTag: '출고값과 차이',
runningCount: '● {count} 실행 중',
projectStoreTag: '프로젝트 store',
+ healthTitle: '설정 상태',
+ healthGroups: '스냅샷 그룹',
+ healthFields: '스냅샷 필드',
+ healthOwned: '커스텀 작업',
+ queueTitle: '실행 대기열',
+ depsPopSub: '파라미터 {count}개 그룹',
+ depsPopTaken: '관리됨',
jsonEditBtn: 'JSON 편집',
openFileBtn: '파일 열기',
openDataBtn: '데이터 위치 열기',
@@ -700,6 +735,13 @@ const ES: WebviewStrings = {
diffTag: 'difiere de fábrica',
runningCount: '● {count} en ejecución',
projectStoreTag: 'store del proyecto',
+ healthTitle: 'Estado de la configuración',
+ healthGroups: 'Grupos de snapshot',
+ healthFields: 'Campos de snapshot',
+ healthOwned: 'Tareas personalizadas',
+ queueTitle: 'Cola de ejecución',
+ depsPopSub: '{count} grupo(s) de configuración',
+ depsPopTaken: 'Gestionado',
jsonEditBtn: 'Editar JSON',
openFileBtn: 'Abrir archivo',
openDataBtn: 'Abrir ubicación de datos',
From 198b93e0838271eadb7cc2793cdc48cc6b210baa Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 01:25:34 +0800
Subject: [PATCH 2/7] fix(console): hover pop -> body-level fixed singleton
(scroll-clip immune), seg switch 1.2s suppress + fade replay
---
media/console/console.css | 77 ++++++++++++++++-----------------
media/console/console.js | 11 ++++-
media/console/taskCard.js | 91 +++++++++++++++++++++++++++++++--------
3 files changed, 121 insertions(+), 58 deletions(-)
diff --git a/media/console/console.css b/media/console/console.css
index 838157b..a1be515 100644
--- a/media/console/console.css
+++ b/media/console/console.css
@@ -1204,86 +1204,83 @@ button.secondary:hover:not(:disabled) { background: var(--vscode-button-secondar
.task-card[data-run="enqueued"] .rc-dot { background: var(--warn); }
.task-card[data-run="armed"] .rc-dot { background: var(--ok); opacity: .75; }
-/* ═══ 任务卡悬停弹出:参数分组概要(毛玻璃 · 只读) ═══════════ */
-.task-card--pop { position: relative; overflow: visible; }
-.task-card--pop .pop {
- position: absolute;
- top: -8px;
- left: calc(100% + 12px);
- z-index: 80;
+/* ═══ 任务卡悬停弹出:body 级固定定位(滚动容器裁剪免疫 · 毛玻璃 · 只读) ═══ */
+.task-card--pop:hover { border-color: color-mix(in srgb, var(--run) 40%, var(--border-strong)); }
+
+.gpop {
+ position: fixed;
+ z-index: 9999;
width: 250px;
+ max-height: min(58vh, 420px);
+ overflow-y: auto;
padding: 10px 12px;
border: 1px solid var(--border-strong);
border-radius: 10px;
- background: color-mix(in srgb, var(--surface-raised, var(--surface)) 82%, transparent);
+ background: color-mix(in srgb, var(--surface-raised, var(--surface)) 88%, transparent);
backdrop-filter: blur(18px) saturate(1.5);
-webkit-backdrop-filter: blur(18px) saturate(1.5);
box-shadow: 0 14px 38px color-mix(in srgb, #000 40%, transparent);
opacity: 0;
- transform: translateX(-8px) scale(.97);
+ visibility: hidden;
+ transform: translateY(4px) scale(.98);
pointer-events: none;
- transition: opacity .18s ease, transform .18s cubic-bezier(.2,.9,.3,1.2);
- transition-delay: 0s;
+ transition: opacity .16s ease, transform .16s cubic-bezier(.2,.9,.3,1.2), visibility 0s linear .16s;
}
-.task-card--pop:hover .pop { opacity: 1; transform: translateX(0) scale(1); transition-delay: .22s; }
-.task-card--pop .pop::before {
- content: '';
- position: absolute;
- top: 20px; left: -6px;
- width: 10px; height: 10px;
- transform: rotate(45deg);
- background: var(--surface-raised, var(--surface));
- border-bottom: 1px solid var(--border-strong);
- border-right: 1px solid var(--border-strong);
-}
-@keyframes popfade { from { opacity: 0; transform: translateX(-8px); } to { opacity: 1; transform: none; } }
-.task-card--pop .pop-title, .task-card--pop .grp { animation: popfade .24s ease both; }
-.task-card--pop .pop-title {
+.gpop.is-visible {
+ opacity: 1;
+ visibility: visible;
+ transform: none;
+ pointer-events: auto; /* 内容超高时允许内部滚动 */
+ transition: opacity .16s ease, transform .16s cubic-bezier(.2,.9,.3,1.2), visibility 0s;
+}
+@keyframes popfade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
+.gpop .pop-title {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 2px; font-size: 12px; font-weight: 700;
+ animation: popfade .24s ease both;
}
-.task-card--pop .pop-sub { font-size: 10px; font-weight: 400; color: var(--muted); }
-.task-card--pop .grp { margin: 8px 0; }
-.task-card--pop .grp-head {
+.gpop .pop-sub { font-size: 10px; font-weight: 400; color: var(--muted); }
+.gpop .grp { margin: 8px 0; animation: popfade .24s ease both; }
+.gpop .grp-head {
display: flex; align-items: center; justify-content: space-between;
padding: 0 2px 2px;
font-size: 10px; letter-spacing: .08em; color: var(--muted);
}
-.task-card--pop .grp-head b { font-size: 12px; font-weight: 600; letter-spacing: 0; color: var(--vscode-editor-foreground); }
-.task-card--pop .grp-head .cnt {
+.gpop .grp-head b { font-size: 12px; font-weight: 600; letter-spacing: 0; color: var(--vscode-editor-foreground); }
+.gpop .grp-head .cnt {
padding: 1px 8px; border-radius: 999px;
background: color-mix(in srgb, var(--run) 14%, transparent);
color: var(--run); font-size: 10px;
}
-.task-card--pop .grp-body { position: relative; margin: 2px 0 0 8px; padding-left: 12px; }
-.task-card--pop .grp-body::before {
+.gpop .grp-body { position: relative; margin: 2px 0 0 8px; padding-left: 12px; }
+.gpop .grp-body::before {
content: '';
position: absolute;
top: 2px; bottom: 6px; left: 0;
width: 2px; border-radius: 1px;
background: linear-gradient(180deg, var(--border-strong), transparent);
}
-.task-card--pop .it {
+.gpop .it {
position: relative;
- display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ display: flex; align-items: center;
margin: 4px 0; padding: 4px 9px;
border-left: 2px solid var(--border-strong);
border-radius: 6px;
background: color-mix(in srgb, var(--surface-hover) 72%, transparent);
font-size: 12px; overflow: hidden;
}
-.task-card--pop .it::before {
+.gpop .it::before {
content: '';
position: absolute;
top: 50%; left: -12px;
width: 10px; height: 1px;
background: var(--border-strong); opacity: .7;
}
-.task-card--pop .it > span:first-child {
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.task-card--pop .it .st { padding: 1px 7px; border-radius: 999px; font-size: 10px; white-space: nowrap; }
-.task-card--pop .it .st.ok { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
+.gpop .it > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* 分段切换内容淡入重放(弹出层规范配套:切 Tab 后 1.2s 抑制弹出 + 内容重放淡入) */
+.page.is-entering { animation: pagefade .26s ease both; }
+@keyframes pagefade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
diff --git a/media/console/console.js b/media/console/console.js
index 952748b..2f9c5ba 100644
--- a/media/console/console.js
+++ b/media/console/console.js
@@ -16,6 +16,8 @@
// ── 分段切换 ─────────────────────────────────────────────────────────
function switchSeg(seg) {
+ // 弹出层规范:切 Tab 后 1.2s 内抑制悬停弹出 + 目标页内容淡入重放
+ globalThis.TaskLauncherTaskCard?.suppressPopups?.();
for (const item of document.querySelectorAll('.lay-side__item')) {
const active = item.dataset.seg === seg;
item.classList.toggle('is-active', active);
@@ -24,7 +26,14 @@
$('pageTasks').hidden = seg !== 'tasks';
$('pageGame').hidden = seg !== 'game';
$('pageConfig').hidden = seg !== 'config';
- $('pageAccounts').hidden = seg !== 'accounts'; }
+ $('pageAccounts').hidden = seg !== 'accounts';
+ const target = document.getElementById(`page${seg.charAt(0).toUpperCase()}${seg.slice(1)}`);
+ if (target) {
+ target.classList.remove('is-entering');
+ void target.offsetWidth; // 强制 reflow,保证动画每次都重放
+ target.classList.add('is-entering');
+ }
+ }
// ── 配置分段:全局配置组卡片(复用 configPanel,伪 task = __global__::组名) ──
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 8da1777..942a11c 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -126,13 +126,22 @@
return [sync, reset];
}
- /** 悬停弹出:参数表单分组概要(数据与 configPanel 同源:configGroups / groupLabels) */
- function buildGroupPop(task, schema) {
+ /* ── 悬停弹出(body 级单例 + fixed 定位)──────────────────────────
+ 卡内 absolute + left:calc(100%+12px) 会被任务列表滚动容器的
+ overflow 裁掉(lab 里 B/C 布局同款翻车),挂到 body 上用
+ getBoundingClientRect + 视口夹紧定位,右缘对齐卡片、优先下方、
+ 底部放不下自动翻上方。只读无按钮;内容与 configPanel 同源。 */
+ let popEl = null;
+ let popHideTimer = 0;
+ let popSuppressUntil = 0;
+
+ /** 弹层内容:参数分组概要(configGroups / groupLabels / displayKey) */
+ function groupPopContent(task, schema) {
const groups = schema?.configGroups && typeof schema.configGroups === 'object'
? Object.entries(schema.configGroups) : [];
if (!groups.length) return null;
- const pop = document.createElement('div');
- pop.className = 'pop';
+ const labels = Object.fromEntries((schema?.fields || []).map((f) => [f.key, f.displayKey || f.key]));
+ const frag = document.createDocumentFragment();
const title = document.createElement('div');
title.className = 'pop-title';
title.textContent = schema?.displayName || task.displayName || '';
@@ -140,7 +149,7 @@
sub.className = 'pop-sub';
sub.textContent = t('depsPopSub', { count: groups.length });
title.appendChild(sub);
- pop.appendChild(title);
+ frag.appendChild(title);
for (const [key, fieldKeys] of groups) {
const list = Array.isArray(fieldKeys) ? fieldKeys : [];
const grp = document.createElement('div');
@@ -155,23 +164,69 @@
head.append(name, cnt);
const body = document.createElement('div');
body.className = 'grp-body';
- for (const fieldKey of list.slice(0, 3)) {
+ for (const fieldKey of list.slice(0, 4)) {
const it = document.createElement('div');
it.className = 'it';
const label = document.createElement('span');
- label.textContent = fieldKey;
- const st = document.createElement('span');
- st.className = 'st ok';
- st.textContent = t('depsPopTaken');
- it.append(label, st);
+ label.textContent = labels[fieldKey] || fieldKey;
+ it.appendChild(label);
body.appendChild(it);
}
grp.append(head, body);
- pop.appendChild(grp);
+ frag.appendChild(grp);
}
- return pop;
+ return frag;
}
+ function hideHoverPop(immediate = false) {
+ window.clearTimeout(popHideTimer);
+ if (!popEl) return;
+ if (immediate) {
+ popEl.classList.remove('is-visible');
+ return;
+ }
+ popHideTimer = window.setTimeout(() => popEl?.classList.remove('is-visible'), 120);
+ }
+
+ function showHoverPop(card, task, schema) {
+ if (Date.now() < popSuppressUntil) return;
+ const content = groupPopContent(task, schema);
+ if (!content) return;
+ if (!popEl) {
+ popEl = document.createElement('div');
+ popEl.className = 'gpop';
+ popEl.setAttribute('role', 'tooltip');
+ popEl.addEventListener('mouseenter', () => window.clearTimeout(popHideTimer));
+ popEl.addEventListener('mouseleave', () => hideHoverPop());
+ document.body.appendChild(popEl);
+ }
+ popEl.replaceChildren(content);
+ popEl.classList.remove('is-visible'); // 先归零再测量,避免位置跳变闪烁
+ const rect = card.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const pw = popEl.offsetWidth;
+ const ph = popEl.offsetHeight;
+ let left = rect.right - pw - 10; // 右缘对齐卡片右缘
+ left = Math.max(8, Math.min(left, vw - pw - 8));
+ let top = rect.bottom + 8; // 优先放下方
+ if (top + ph > vh - 8) top = rect.top - ph - 8; // 底部放不下 → 翻上方
+ top = Math.max(8, Math.min(top, Math.max(8, vh - ph - 8)));
+ popEl.style.left = `${Math.round(left)}px`;
+ popEl.style.top = `${Math.round(top)}px`;
+ popEl.classList.add('is-visible');
+ }
+
+ /** 切分段后短暂抑制弹出(弹出层规范:点 Tab 后 1.2s 内不弹 + 内容淡入重放) */
+ function suppressPopups() {
+ popSuppressUntil = Date.now() + 1200;
+ hideHoverPop(true);
+ }
+
+ // 列表滚动 / 窗口缩放时弹层立刻收起,避免钉在旧位置
+ window.addEventListener('scroll', () => hideHoverPop(true), { capture: true, passive: true });
+ window.addEventListener('resize', () => hideHoverPop(true));
+
function buildTaskCard(task) {
const key = taskKey(task);
const schema = state.schemas[key];
@@ -225,10 +280,11 @@
// 点击「参数」按钮时由 console.js 把这个节点搬运进抽屉显示。
const configPanel = buildConfigPanel(task, schema);
card.append(header, configPanel);
- const pop = buildGroupPop(task, schema);
- if (pop) {
+ // 有参数分组才挂悬停弹出(内容按需构建,body 级单例见 showHoverPop)
+ if (schema?.configGroups && Object.keys(schema.configGroups).length) {
card.classList.add('task-card--pop');
- card.appendChild(pop);
+ card.addEventListener('mouseenter', () => showHoverPop(card, task, schema));
+ card.addEventListener('mouseleave', () => hideHoverPop());
}
return card;
}
@@ -464,6 +520,7 @@
function renderTasks(tasks) {
state.currentTasks = tasks;
+ hideHoverPop(true); // 重渲染后旧弹层位置失效,直接收起
// show_in_task_tab=False 的任务不进列表(框架原生不消费,插件按隐藏对待)
const listed = tasks.filter((task) => state.schemas[taskKey(task)]?.showInTaskTab !== false);
const visible = listed.filter(matches);
@@ -483,5 +540,5 @@
renderTasks(state.currentTasks);
}
- globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup };
+ globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup, suppressPopups };
})();
From 220ae27e0d8ee272205cf4640a531727769e5b45 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 01:57:25 +0800
Subject: [PATCH 3/7] fix(console): clear pending pop hide timer on show - fast
card-to-card hover killed new popup after ~120ms
---
media/console/taskCard.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 942a11c..75663a1 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -189,6 +189,8 @@
}
function showHoverPop(card, task, schema) {
+ window.clearTimeout(popHideTimer); // 关键:撤掉上一张卡 mouseleave 挂起的隐藏定时器,
+ // 否则快速移到相邻卡片时,新弹层显示 ~40ms 后被旧定时器藏掉(真机复现过)
if (Date.now() < popSuppressUntil) return;
const content = groupPopContent(task, schema);
if (!content) return;
From 32235bf4fecf6793518bcbf7527d8e361a4be2b7 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 02:27:39 +0800
Subject: [PATCH 4/7] fix(console): hover pop fallback for flat configs - tasks
without configGroups collapse fields into a single pseudo-group instead of
not popping
---
media/console/taskCard.js | 30 ++++++++++++++++++++----------
1 file changed, 20 insertions(+), 10 deletions(-)
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 75663a1..cec0e07 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -135,36 +135,46 @@
let popHideTimer = 0;
let popSuppressUntil = 0;
- /** 弹层内容:参数分组概要(configGroups / groupLabels / displayKey) */
+ /**
+ * 弹层内容:参数分组概要(configGroups / groupLabels / displayKey)。
+ * 有分组层次 → 按组展示;没层次但有字段 → 折叠成单个「参数」伪组列扁平字段;
+ * 两者皆无(连字段都没有)→ 不弹。
+ */
function groupPopContent(task, schema) {
- const groups = schema?.configGroups && typeof schema.configGroups === 'object'
+ const fields = schema?.fields || [];
+ const groupEntries = schema?.configGroups && typeof schema.configGroups === 'object'
? Object.entries(schema.configGroups) : [];
- if (!groups.length) return null;
- const labels = Object.fromEntries((schema?.fields || []).map((f) => [f.key, f.displayKey || f.key]));
+ const labels = Object.fromEntries(fields.map((f) => [f.key, f.displayKey || f.key]));
+ const entries = groupEntries.length
+ ? groupEntries
+ : (fields.length ? [[null, fields.map((f) => f.key)]] : []);
+ if (!entries.length) return null;
const frag = document.createDocumentFragment();
const title = document.createElement('div');
title.className = 'pop-title';
title.textContent = schema?.displayName || task.displayName || '';
const sub = document.createElement('span');
sub.className = 'pop-sub';
- sub.textContent = t('depsPopSub', { count: groups.length });
+ sub.textContent = groupEntries.length
+ ? t('depsPopSub', { count: groupEntries.length })
+ : t('itemsCount', { count: fields.length });
title.appendChild(sub);
frag.appendChild(title);
- for (const [key, fieldKeys] of groups) {
+ for (const [key, fieldKeys] of entries) {
const list = Array.isArray(fieldKeys) ? fieldKeys : [];
const grp = document.createElement('div');
grp.className = 'grp';
const head = document.createElement('div');
head.className = 'grp-head';
const name = document.createElement('b');
- name.textContent = schema.groupLabels?.[key] || key;
+ name.textContent = key === null ? t('parameters') : (schema.groupLabels?.[key] || key);
const cnt = document.createElement('span');
cnt.className = 'cnt';
cnt.textContent = t('itemsCount', { count: list.length });
head.append(name, cnt);
const body = document.createElement('div');
body.className = 'grp-body';
- for (const fieldKey of list.slice(0, 4)) {
+ for (const fieldKey of list.slice(0, key === null ? 6 : 4)) {
const it = document.createElement('div');
it.className = 'it';
const label = document.createElement('span');
@@ -282,8 +292,8 @@
// 点击「参数」按钮时由 console.js 把这个节点搬运进抽屉显示。
const configPanel = buildConfigPanel(task, schema);
card.append(header, configPanel);
- // 有参数分组才挂悬停弹出(内容按需构建,body 级单例见 showHoverPop)
- if (schema?.configGroups && Object.keys(schema.configGroups).length) {
+ // 悬停弹出:有分组层次按组展示,没层次但有字段折叠成「参数」伪组(见 groupPopContent)
+ if ((schema?.configGroups && Object.keys(schema.configGroups).length) || schema?.fields?.length) {
card.classList.add('task-card--pop');
card.addEventListener('mouseenter', () => showHoverPop(card, task, schema));
card.addEventListener('mouseleave', () => hideHoverPop());
From c69b38145e7b76722520342b0743cabddb3b60c5 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 02:38:13 +0800
Subject: [PATCH 5/7] feat(console): popup lists all fields (internally
scrollable) and stays open while pointer is over it; own internal scroll no
longer auto-closes it
---
media/console/console.css | 18 ++++++++++++++++++
media/console/taskCard.js | 15 +++++++++++----
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/media/console/console.css b/media/console/console.css
index a1be515..bffe145 100644
--- a/media/console/console.css
+++ b/media/console/console.css
@@ -1233,6 +1233,24 @@ button.secondary:hover:not(:disabled) { background: var(--vscode-button-secondar
pointer-events: auto; /* 内容超高时允许内部滚动 */
transition: opacity .16s ease, transform .16s cubic-bezier(.2,.9,.3,1.2), visibility 0s;
}
+/* 朝向桥:伪元素补住卡片与弹层之间的 8px 间隙,鼠标从卡片跨进弹层时
+ popEl 的 mouseenter 先清掉卡片 mouseleave 挂起的隐藏定时器,弹层不闪收 */
+.gpop[data-pos="below"]::after,
+.gpop[data-pos="above"]::after {
+ content: '';
+ position: absolute;
+ left: 0; right: 0; height: 12px;
+}
+.gpop[data-pos="below"]::after { top: -12px; }
+.gpop[data-pos="above"]::after { bottom: -12px; }
+/* 弹层内部滚动条(内容超高时全量翻阅) */
+.gpop::-webkit-scrollbar { width: 8px; }
+.gpop::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--border-strong) 80%, transparent);
+ border-radius: 4px;
+}
+.gpop::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
+.gpop::-webkit-scrollbar-track { background: transparent; }
@keyframes popfade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.gpop .pop-title {
display: flex; align-items: baseline; justify-content: space-between;
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index cec0e07..ff83efd 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -174,7 +174,7 @@
head.append(name, cnt);
const body = document.createElement('div');
body.className = 'grp-body';
- for (const fieldKey of list.slice(0, key === null ? 6 : 4)) {
+ for (const fieldKey of list) {
const it = document.createElement('div');
it.className = 'it';
const label = document.createElement('span');
@@ -222,8 +222,10 @@
let left = rect.right - pw - 10; // 右缘对齐卡片右缘
left = Math.max(8, Math.min(left, vw - pw - 8));
let top = rect.bottom + 8; // 优先放下方
- if (top + ph > vh - 8) top = rect.top - ph - 8; // 底部放不下 → 翻上方
+ const below = top + ph <= vh - 8;
+ if (!below) top = rect.top - ph - 8; // 底部放不下 → 翻上方
top = Math.max(8, Math.min(top, Math.max(8, vh - ph - 8)));
+ popEl.dataset.pos = below ? 'below' : 'above'; // 桥的朝向:CSS 伪元素补住与卡片间的间隙
popEl.style.left = `${Math.round(left)}px`;
popEl.style.top = `${Math.round(top)}px`;
popEl.classList.add('is-visible');
@@ -235,8 +237,13 @@
hideHoverPop(true);
}
- // 列表滚动 / 窗口缩放时弹层立刻收起,避免钉在旧位置
- window.addEventListener('scroll', () => hideHoverPop(true), { capture: true, passive: true });
+ // 列表滚动 / 窗口缩放时弹层立刻收起,避免钉在旧位置。
+ // 弹层自身内部滚动不收——capture 阶段会收到非冒泡的 scroll 事件,
+ // 用户正在弹层里翻全量内容,收掉等于毁掉「悬停可进弹层」。
+ window.addEventListener('scroll', (event) => {
+ if (popEl && event.target instanceof Node && popEl.contains(event.target)) return;
+ hideHoverPop(true);
+ }, { capture: true, passive: true });
window.addEventListener('resize', () => hideHoverPop(true));
function buildTaskCard(task) {
From 0cfb6eb715331cb9347e0a7fcfe5cc32b405ab38 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 02:44:06 +0800
Subject: [PATCH 6/7] =?UTF-8?q?feat(console):=20popup=20expands=20to=20the?=
=?UTF-8?q?=20LEFT=20of=20the=20card=20(top-aligned,=20never=20covering=20?=
=?UTF-8?q?other=20tasks);=20grouped=20schemas=20also=20list=20ungrouped?=
=?UTF-8?q?=20fields=20as=20=E5=85=B6=E4=BB=96=E5=8F=82=E6=95=B0=20group?=
=?UTF-8?q?=20(6=20locales)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
media/console/console.css | 10 ----------
media/console/taskCard.js | 40 +++++++++++++++++++++++----------------
src/localization.ts | 6 ++++++
3 files changed, 30 insertions(+), 26 deletions(-)
diff --git a/media/console/console.css b/media/console/console.css
index bffe145..3f16656 100644
--- a/media/console/console.css
+++ b/media/console/console.css
@@ -1233,16 +1233,6 @@ button.secondary:hover:not(:disabled) { background: var(--vscode-button-secondar
pointer-events: auto; /* 内容超高时允许内部滚动 */
transition: opacity .16s ease, transform .16s cubic-bezier(.2,.9,.3,1.2), visibility 0s;
}
-/* 朝向桥:伪元素补住卡片与弹层之间的 8px 间隙,鼠标从卡片跨进弹层时
- popEl 的 mouseenter 先清掉卡片 mouseleave 挂起的隐藏定时器,弹层不闪收 */
-.gpop[data-pos="below"]::after,
-.gpop[data-pos="above"]::after {
- content: '';
- position: absolute;
- left: 0; right: 0; height: 12px;
-}
-.gpop[data-pos="below"]::after { top: -12px; }
-.gpop[data-pos="above"]::after { bottom: -12px; }
/* 弹层内部滚动条(内容超高时全量翻阅) */
.gpop::-webkit-scrollbar { width: 8px; }
.gpop::-webkit-scrollbar-thumb {
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index ff83efd..842d874 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -137,7 +137,8 @@
/**
* 弹层内容:参数分组概要(configGroups / groupLabels / displayKey)。
- * 有分组层次 → 按组展示;没层次但有字段 → 折叠成单个「参数」伪组列扁平字段;
+ * 有分组层次 → 按组展示 + 「其他参数」(没进任何 configGroups 的字段也是参数);
+ * 没层次但有字段 → 折叠成单个「参数」伪组列扁平字段;
* 两者皆无(连字段都没有)→ 不弹。
*/
function groupPopContent(task, schema) {
@@ -145,9 +146,19 @@
const groupEntries = schema?.configGroups && typeof schema.configGroups === 'object'
? Object.entries(schema.configGroups) : [];
const labels = Object.fromEntries(fields.map((f) => [f.key, f.displayKey || f.key]));
- const entries = groupEntries.length
- ? groupEntries
- : (fields.length ? [[null, fields.map((f) => f.key)]] : []);
+ const entries = [];
+ if (groupEntries.length) {
+ const grouped = new Set();
+ for (const [key, fieldKeys] of groupEntries) {
+ const list = Array.isArray(fieldKeys) ? fieldKeys : [];
+ for (const fieldKey of list) grouped.add(fieldKey);
+ entries.push({ label: schema.groupLabels?.[key] || key, keys: list });
+ }
+ const others = fields.filter((f) => !grouped.has(f.key)).map((f) => f.key);
+ if (others.length) entries.push({ label: t('depsPopOthers'), keys: others });
+ } else if (fields.length) {
+ entries.push({ label: t('parameters'), keys: fields.map((f) => f.key) });
+ }
if (!entries.length) return null;
const frag = document.createDocumentFragment();
const title = document.createElement('div');
@@ -156,25 +167,24 @@
const sub = document.createElement('span');
sub.className = 'pop-sub';
sub.textContent = groupEntries.length
- ? t('depsPopSub', { count: groupEntries.length })
+ ? t('depsPopSub', { count: entries.length })
: t('itemsCount', { count: fields.length });
title.appendChild(sub);
frag.appendChild(title);
- for (const [key, fieldKeys] of entries) {
- const list = Array.isArray(fieldKeys) ? fieldKeys : [];
+ for (const entry of entries) {
const grp = document.createElement('div');
grp.className = 'grp';
const head = document.createElement('div');
head.className = 'grp-head';
const name = document.createElement('b');
- name.textContent = key === null ? t('parameters') : (schema.groupLabels?.[key] || key);
+ name.textContent = entry.label;
const cnt = document.createElement('span');
cnt.className = 'cnt';
- cnt.textContent = t('itemsCount', { count: list.length });
+ cnt.textContent = t('itemsCount', { count: entry.keys.length });
head.append(name, cnt);
const body = document.createElement('div');
body.className = 'grp-body';
- for (const fieldKey of list) {
+ for (const fieldKey of entry.keys) {
const it = document.createElement('div');
it.className = 'it';
const label = document.createElement('span');
@@ -219,13 +229,11 @@
const vh = window.innerHeight;
const pw = popEl.offsetWidth;
const ph = popEl.offsetHeight;
- let left = rect.right - pw - 10; // 右缘对齐卡片右缘
+ // 左侧展开:右缘距卡片左缘 8px,垂直顶对齐卡片——向下/向右弹都会盖住别的任务卡
+ let left = rect.left - pw - 8;
+ if (left < 8) left = Math.min(rect.right + 8, vw - pw - 8); // 左侧放不下 → 回退右侧
left = Math.max(8, Math.min(left, vw - pw - 8));
- let top = rect.bottom + 8; // 优先放下方
- const below = top + ph <= vh - 8;
- if (!below) top = rect.top - ph - 8; // 底部放不下 → 翻上方
- top = Math.max(8, Math.min(top, Math.max(8, vh - ph - 8)));
- popEl.dataset.pos = below ? 'below' : 'above'; // 桥的朝向:CSS 伪元素补住与卡片间的间隙
+ const top = Math.max(8, Math.min(rect.top, Math.max(8, vh - ph - 8)));
popEl.style.left = `${Math.round(left)}px`;
popEl.style.top = `${Math.round(top)}px`;
popEl.classList.add('is-visible');
diff --git a/src/localization.ts b/src/localization.ts
index 2fe4206..6b98480 100644
--- a/src/localization.ts
+++ b/src/localization.ts
@@ -249,6 +249,7 @@ const ZH_CN: WebviewStrings = {
queueTitle: '执行队列',
depsPopSub: '参数分 {count} 组',
depsPopTaken: '已接管',
+ depsPopOthers: '其他参数',
jsonEditBtn: 'JSON 编辑',
openFileBtn: '打开文件',
openDataBtn: '打开数据位置',
@@ -446,6 +447,7 @@ const EN: WebviewStrings = {
queueTitle: 'Execution queue',
depsPopSub: '{count} config group(s)',
depsPopTaken: 'Owned',
+ depsPopOthers: 'Other parameters',
jsonEditBtn: 'JSON edit',
openFileBtn: 'Open file',
openDataBtn: 'Open data location',
@@ -504,6 +506,7 @@ const ZH_TW: WebviewStrings = {
queueTitle: '執行佇列',
depsPopSub: '參數分 {count} 組',
depsPopTaken: '已接管',
+ depsPopOthers: '其他參數',
jsonEditBtn: 'JSON 編輯',
openFileBtn: '開啟檔案',
openDataBtn: '開啟資料位置',
@@ -584,6 +587,7 @@ const JA: WebviewStrings = {
queueTitle: '実行キュー',
depsPopSub: 'パラメータ {count} グループ',
depsPopTaken: '引き継ぎ済み',
+ depsPopOthers: 'その他のパラメータ',
jsonEditBtn: 'JSON 編集',
openFileBtn: 'ファイルを開く',
openDataBtn: 'データ場所を開く',
@@ -663,6 +667,7 @@ const KO: WebviewStrings = {
queueTitle: '실행 대기열',
depsPopSub: '파라미터 {count}개 그룹',
depsPopTaken: '관리됨',
+ depsPopOthers: '기타 파라미터',
jsonEditBtn: 'JSON 편집',
openFileBtn: '파일 열기',
openDataBtn: '데이터 위치 열기',
@@ -742,6 +747,7 @@ const ES: WebviewStrings = {
queueTitle: 'Cola de ejecución',
depsPopSub: '{count} grupo(s) de configuración',
depsPopTaken: 'Gestionado',
+ depsPopOthers: 'Otros parámetros',
jsonEditBtn: 'Editar JSON',
openFileBtn: 'Abrir archivo',
openDataBtn: 'Abrir ubicación de datos',
From 78d5f2f346dcdb62b97d0017e8cdb34a6e05410e Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 03:01:31 +0800
Subject: [PATCH 7/7] fix(console): hover popup is LEFT-ONLY - shrink width
(floor 180px) to fit, clamp to viewport edge; right-side fallback removed
---
media/console/taskCard.js | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 842d874..ca46ea5 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -225,14 +225,16 @@
popEl.replaceChildren(content);
popEl.classList.remove('is-visible'); // 先归零再测量,避免位置跳变闪烁
const rect = card.getBoundingClientRect();
- const vw = window.innerWidth;
const vh = window.innerHeight;
+ // 恒定左侧展开(用户要求:不回退右侧)。空间不足先收窄宽度(下限 180px),
+ // 连下限都放不下就贴视口左缘(left 钳到 8),允许少量压住卡片——悬停即走,无碍。
+ const POP_W = 250; // 与 console.css .gpop 的 width 保持一致
+ const availLeft = rect.left - 16; // 卡片间隙 8px + 视口边距 8px
+ popEl.style.width = `${Math.round(Math.max(180, Math.min(POP_W, availLeft)))}px`;
const pw = popEl.offsetWidth;
const ph = popEl.offsetHeight;
// 左侧展开:右缘距卡片左缘 8px,垂直顶对齐卡片——向下/向右弹都会盖住别的任务卡
- let left = rect.left - pw - 8;
- if (left < 8) left = Math.min(rect.right + 8, vw - pw - 8); // 左侧放不下 → 回退右侧
- left = Math.max(8, Math.min(left, vw - pw - 8));
+ const left = Math.max(8, rect.left - pw - 8);
const top = Math.max(8, Math.min(rect.top, Math.max(8, vh - ph - 8)));
popEl.style.left = `${Math.round(left)}px`;
popEl.style.top = `${Math.round(top)}px`;