+
+
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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] =?UTF-8?q?feat(console):=20popup=20expands=20to=20t?=
=?UTF-8?q?he=20LEFT=20of=20the=20card=20(top-aligned,=20never=20covering?=
=?UTF-8?q?=20other=20tasks);=20grouped=20schemas=20also=20list=20ungroupe?=
=?UTF-8?q?d=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 07/12] 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`;
From 983e2d0b76260709af1d872380c07c7d155c536e Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 03:07:35 +0800
Subject: [PATCH 08/12] feat(console): hover popup renders BOTH group kinds
equally - static configGroups and conditional visibility groups
(field.type.sub_configs) with rule rows, nesting and dual rendering (6
locales)
---
media/console/console.css | 15 ++++
media/console/taskCard.js | 184 +++++++++++++++++++++++++++++++-------
src/localization.ts | 18 ++++
3 files changed, 186 insertions(+), 31 deletions(-)
diff --git a/media/console/console.css b/media/console/console.css
index 3f16656..5c2d154 100644
--- a/media/console/console.css
+++ b/media/console/console.css
@@ -1241,6 +1241,21 @@ button.secondary:hover:not(:disabled) { background: var(--vscode-button-secondar
}
.gpop::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
.gpop::-webkit-scrollbar-track { background: transparent; }
+/* 条件组(field.type.sub_configs 显隐组):与静态组一视同仁,蓝色系区分语义 */
+.gpop .grp-tag {
+ padding: 1px 7px;
+ border-radius: 999px;
+ font-size: 10px;
+ background: color-mix(in srgb, var(--vscode-charts-blue, #3794ff) 16%, transparent);
+ color: var(--vscode-charts-blue, #3794ff);
+}
+.gpop .it--rule {
+ border-left-color: var(--vscode-charts-blue, #3794ff);
+ background: color-mix(in srgb, var(--vscode-charts-blue, #3794ff) 10%, transparent);
+ font-weight: 600;
+}
+/* 条件组内的嵌套子组(条件组套条件组 / 静态组)保持节奏一致的间距 */
+.gpop .grp-body .grp { margin: 6px 0 2px; }
@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 ca46ea5..5b7d9d2 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -136,63 +136,185 @@
let popSuppressUntil = 0;
/**
- * 弹层内容:参数分组概要(configGroups / groupLabels / displayKey)。
- * 有分组层次 → 按组展示 + 「其他参数」(没进任何 configGroups 的字段也是参数);
- * 没层次但有字段 → 折叠成单个「参数」伪组列扁平字段;
- * 两者皆无(连字段都没有)→ 不弹。
+ * field.type.sub_configs → [{ valueLabel, keys }](对齐 configPanel 的规则归一):
+ * boolean 开关 → 开/关规则;下拉/多选 → 选项规则(显示名走 sub_config_labels > options > 原值)。
+ */
+ function condRulesOf(field) {
+ const typeMeta = field?.type;
+ const rules = typeMeta && typeof typeMeta.sub_configs === 'object' && !Array.isArray(typeMeta.sub_configs)
+ ? typeMeta.sub_configs : null;
+ if (!rules || !Object.keys(rules).length) return [];
+ const labels = typeof typeMeta.sub_config_labels === 'object' && typeMeta.sub_config_labels
+ ? typeMeta.sub_config_labels : {};
+ const isBool = typeof field.default === 'boolean' || typeof field.value === 'boolean';
+ const opts = typeMeta.options && typeof typeMeta.options === 'object' && !Array.isArray(typeMeta.options)
+ ? typeMeta.options : null;
+ const out = [];
+ for (const [choice, controlled] of Object.entries(rules)) {
+ const keys = (Array.isArray(controlled) ? controlled : [controlled])
+ .filter((k) => typeof k === 'string');
+ if (!keys.length) continue;
+ let valueLabel = labels[choice];
+ if (!valueLabel && isBool) valueLabel = t(String(choice).toLowerCase() === 'true' ? 'boolOn' : 'boolOff');
+ if (!valueLabel) valueLabel = opts ? String(opts[choice] ?? choice) : String(choice);
+ out.push({ valueLabel, keys });
+ }
+ return out;
+ }
+
+ /**
+ * 弹层内容:参数分组概要 —— 两种组一视同仁渲染,可互相嵌套:
+ * · 静态组(configGroups):字段按组归类,组内可嵌套子静态组;
+ * · 条件组(field.type.sub_configs):勾选/选中父字段某值才显示的受控字段,
+ * 组头 = 父字段显示名 +「显隐组」徽标,规则行 =「值 → N 项」;
+ * · 嵌套:受控字段自己也有 sub_configs → 在其规则下递归缩进(深度上限 4 防环);
+ * · 同一字段既在静态组又被条件组受控 → 两处都渲染(一视同仁);
+ * · 未归属任何组/条件的字段 → 「其他参数」。
+ * 归并规则与 configPanel 对齐:分组名自身的显隐规则子项吸收进该组 children。
*/
function groupPopContent(task, schema) {
const fields = schema?.fields || [];
- const groupEntries = schema?.configGroups && typeof schema.configGroups === 'object'
+ if (!fields.length) return null;
+ const fieldsByKey = Object.fromEntries(fields.map((f) => [f.key, f]));
+ const labelOf = (key) => fieldsByKey[key]?.displayKey || schema.groupLabels?.[key] || key;
+ const rawGroups = 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 = [];
- 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 groupMap = new Map(rawGroups.map(([k, v]) => [k, Array.isArray(v) ? [...v] : []]));
+
+ // 分组名自身的显隐规则子项吸收进组 children(对齐 configPanel 规则 1)
+ for (const [gkey, children] of [...groupMap]) {
+ const f = fieldsByKey[gkey];
+ if (!f) continue;
+ const declared = new Set(children);
+ for (const rule of condRulesOf(f)) {
+ for (const key of rule.keys) if (!declared.has(key)) children.push(key);
}
- 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) });
+ groupMap.set(gkey, children);
}
- if (!entries.length) return null;
+
+ // 条件组父字段(分组名除外)与受控字段集合
+ const condParents = [];
+ const controlled = new Set();
+ for (const f of fields) {
+ if (groupMap.has(f.key)) continue;
+ const rules = condRulesOf(f);
+ if (rules.length) {
+ condParents.push({ field: f, rules });
+ for (const rule of rules) for (const key of rule.keys) controlled.add(key);
+ }
+ }
+
+ const groupChildren = new Set();
+ for (const children of groupMap.values()) for (const key of children) groupChildren.add(key);
+ const others = fields.filter((f) => !groupMap.has(f.key) && !groupChildren.has(f.key) && !controlled.has(f.key));
+
+ if (!groupMap.size && !condParents.length && !others.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 = groupEntries.length
- ? t('depsPopSub', { count: entries.length })
- : t('itemsCount', { count: fields.length });
+ sub.textContent = t('depsPopSub', { count: groupMap.size + condParents.length + (others.length ? 1 : 0) });
title.appendChild(sub);
frag.appendChild(title);
- for (const entry of entries) {
+
+ function renderItem(key, container, depth) {
+ const it = document.createElement('div');
+ it.className = 'it';
+ const label = document.createElement('span');
+ label.textContent = labelOf(key);
+ it.appendChild(label);
+ container.appendChild(it);
+ const f = fieldsByKey[key];
+ if (f && depth < 4) {
+ const rules = condRulesOf(f);
+ if (rules.length) appendCondGroup(f, rules, container, depth + 1);
+ }
+ }
+
+ /** 一个父字段 = 一个条件组;组内每条规则 = 规则行(值 → N 项)+ 受控字段缩进列表 */
+ function appendCondGroup(parentField, rules, container, depth) {
+ const grp = document.createElement('div');
+ grp.className = 'grp grp--cond';
+ const head = document.createElement('div');
+ head.className = 'grp-head';
+ const name = document.createElement('b');
+ name.textContent = parentField.displayKey || parentField.key;
+ const tag = document.createElement('span');
+ tag.className = 'grp-tag';
+ tag.textContent = t('condGroupTag');
+ head.append(name, tag);
+ const body = document.createElement('div');
+ body.className = 'grp-body';
+ for (const rule of rules) {
+ const ruleRow = document.createElement('div');
+ ruleRow.className = 'it it--rule';
+ const val = document.createElement('span');
+ val.textContent = rule.valueLabel;
+ const cnt = document.createElement('span');
+ cnt.className = 'cnt';
+ cnt.textContent = t('itemsCount', { count: rule.keys.length });
+ ruleRow.append(val, cnt);
+ body.appendChild(ruleRow);
+ const list = document.createElement('div');
+ list.className = 'grp-body';
+ for (const key of rule.keys) renderItem(key, list, depth);
+ body.appendChild(list);
+ }
+ grp.append(head, body);
+ container.appendChild(grp);
+ }
+
+ function appendStaticGroup(gkey, container, depth) {
+ const children = groupMap.get(gkey) || [];
const grp = document.createElement('div');
grp.className = 'grp';
const head = document.createElement('div');
head.className = 'grp-head';
const name = document.createElement('b');
- name.textContent = entry.label;
+ name.textContent = schema.groupLabels?.[gkey] || gkey;
const cnt = document.createElement('span');
cnt.className = 'cnt';
- cnt.textContent = t('itemsCount', { count: entry.keys.length });
+ cnt.textContent = t('itemsCount', { count: children.length });
head.append(name, cnt);
const body = document.createElement('div');
body.className = 'grp-body';
- for (const fieldKey of entry.keys) {
- const it = document.createElement('div');
- it.className = 'it';
- const label = document.createElement('span');
- label.textContent = labels[fieldKey] || fieldKey;
- it.appendChild(label);
- body.appendChild(it);
+ for (const key of children) {
+ if (groupMap.has(key)) appendStaticGroup(key, body, depth + 1); // 静态组嵌静态组
+ else renderItem(key, body, depth);
}
grp.append(head, body);
+ container.appendChild(grp);
+ }
+
+ // 静态组(嵌套子组随父组渲染,不重复出现在顶层)
+ const nested = new Set();
+ for (const children of groupMap.values()) for (const key of children) if (groupMap.has(key)) nested.add(key);
+ for (const gkey of groupMap.keys()) {
+ if (!nested.has(gkey)) appendStaticGroup(gkey, frag, 0);
+ }
+ // 条件组(与静态组同级,一视同仁;一个父字段一个组)
+ for (const { field, rules } of condParents) {
+ appendCondGroup(field, rules, frag, 0);
+ }
+ // 其他参数(无静态组时的「参数」伪组沿用旧语义标签)
+ if (others.length) {
+ const grp = document.createElement('div');
+ grp.className = 'grp';
+ const head = document.createElement('div');
+ head.className = 'grp-head';
+ const name = document.createElement('b');
+ name.textContent = groupMap.size ? t('depsPopOthers') : t('parameters');
+ const cnt = document.createElement('span');
+ cnt.className = 'cnt';
+ cnt.textContent = t('itemsCount', { count: others.length });
+ head.append(name, cnt);
+ const body = document.createElement('div');
+ body.className = 'grp-body';
+ for (const f of others) renderItem(f.key, body, 0);
+ grp.append(head, body);
frag.appendChild(grp);
}
return frag;
diff --git a/src/localization.ts b/src/localization.ts
index 6b98480..57ae5ff 100644
--- a/src/localization.ts
+++ b/src/localization.ts
@@ -250,6 +250,9 @@ const ZH_CN: WebviewStrings = {
depsPopSub: '参数分 {count} 组',
depsPopTaken: '已接管',
depsPopOthers: '其他参数',
+ condGroupTag: '显隐组',
+ boolOn: '开',
+ boolOff: '关',
jsonEditBtn: 'JSON 编辑',
openFileBtn: '打开文件',
openDataBtn: '打开数据位置',
@@ -448,6 +451,9 @@ const EN: WebviewStrings = {
depsPopSub: '{count} config group(s)',
depsPopTaken: 'Owned',
depsPopOthers: 'Other parameters',
+ condGroupTag: 'Conditional',
+ boolOn: 'On',
+ boolOff: 'Off',
jsonEditBtn: 'JSON edit',
openFileBtn: 'Open file',
openDataBtn: 'Open data location',
@@ -507,6 +513,9 @@ const ZH_TW: WebviewStrings = {
depsPopSub: '參數分 {count} 組',
depsPopTaken: '已接管',
depsPopOthers: '其他參數',
+ condGroupTag: '顯隱組',
+ boolOn: '開',
+ boolOff: '關',
jsonEditBtn: 'JSON 編輯',
openFileBtn: '開啟檔案',
openDataBtn: '開啟資料位置',
@@ -588,6 +597,9 @@ const JA: WebviewStrings = {
depsPopSub: 'パラメータ {count} グループ',
depsPopTaken: '引き継ぎ済み',
depsPopOthers: 'その他のパラメータ',
+ condGroupTag: '表示条件',
+ boolOn: 'オン',
+ boolOff: 'オフ',
jsonEditBtn: 'JSON 編集',
openFileBtn: 'ファイルを開く',
openDataBtn: 'データ場所を開く',
@@ -668,6 +680,9 @@ const KO: WebviewStrings = {
depsPopSub: '파라미터 {count}개 그룹',
depsPopTaken: '관리됨',
depsPopOthers: '기타 파라미터',
+ condGroupTag: '조건 표시',
+ boolOn: '켬',
+ boolOff: '끔',
jsonEditBtn: 'JSON 편집',
openFileBtn: '파일 열기',
openDataBtn: '데이터 위치 열기',
@@ -748,6 +763,9 @@ const ES: WebviewStrings = {
depsPopSub: '{count} grupo(s) de configuración',
depsPopTaken: 'Gestionado',
depsPopOthers: 'Otros parámetros',
+ condGroupTag: 'Condicional',
+ boolOn: 'Sí',
+ boolOff: 'No',
jsonEditBtn: 'Editar JSON',
openFileBtn: 'Abrir archivo',
openDataBtn: 'Abrir ubicación de datos',
From b345da44f67e965d2c3caad1514c610df0e78798 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 04:14:44 +0800
Subject: [PATCH 09/12] feat(console): popup condition groups cap nesting at 2
levels - deeper chains flatten to rows with a blue condition badge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- COND_NEST_CAP=2: 3rd-level+ controlled fields render as flat rows with an
it__cond badge summarizing their own rules (value -> controlled fields),
audited against real ok-end-field probe data (deepest chain = 4 nodes,
Battle Config structure in 5 tasks) so the 250px popup never gets squeezed
- fix: probe's default sub_config_labels use raw strings, so boolean rules
leaked English "True"/"False" into zh UI - force boolOn/boolOff translation
when label equals the raw choice
- fix: self-referencing static group (DailyTask 多账户模式 children=[itself])
was swallowed by the nested-set check; exclude self-reference and guard
recursion with a seen set to survive A->B->A cycles
- css: .it--flat/.it__cond with specificity beating .gpop .it > span nowrap
(first cut clipped long badges to a single 116px line, caught via CDP geometry)
---
media/console/console.css | 15 +++++++++++
media/console/taskCard.js | 56 ++++++++++++++++++++++++++++-----------
2 files changed, 56 insertions(+), 15 deletions(-)
diff --git a/media/console/console.css b/media/console/console.css
index 5c2d154..fa15be8 100644
--- a/media/console/console.css
+++ b/media/console/console.css
@@ -1300,6 +1300,21 @@ button.secondary:hover:not(:disabled) { background: var(--vscode-button-secondar
background: var(--border-strong); opacity: .7;
}
.gpop .it > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* 拍平行(嵌套到 cap 后第 3 层起):字段名 + 条件摘要徽标换行平铺,蓝系对齐条件组语义。
+ 特异性必须压过上方 .gpop .it > span 的 nowrap/ellipsis,否则长徽标被裁成一行 */
+.gpop .it--flat { flex-wrap: wrap; row-gap: 1px; }
+.gpop .it--flat > .it__cond {
+ flex: 1 1 100%;
+ min-width: 0;
+ display: block;
+ font-size: 10px;
+ line-height: 1.4;
+ max-height: 2.8em; /* 最长两条规则摘要封顶两行 */
+ color: var(--vscode-charts-blue, #3794ff);
+ white-space: normal;
+ word-break: break-all;
+ overflow: hidden;
+}
/* 分段切换内容淡入重放(弹出层规范配套:切 Tab 后 1.2s 抑制弹出 + 内容重放淡入) */
.page.is-entering { animation: pagefade .26s ease both; }
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 5b7d9d2..0e1a629 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -155,8 +155,14 @@
.filter((k) => typeof k === 'string');
if (!keys.length) continue;
let valueLabel = labels[choice];
- if (!valueLabel && isBool) valueLabel = t(String(choice).toLowerCase() === 'true' ? 'boolOn' : 'boolOff');
- if (!valueLabel) valueLabel = opts ? String(opts[choice] ?? choice) : String(choice);
+ const rawStr = String(choice);
+ // 探针把缺省 sub_config_labels 填成原始值字符串("True"/"False"),
+ // bool 字段须翻成开/关——否则中文界面漏出英文原值
+ if (isBool && (valueLabel === undefined || valueLabel === rawStr)
+ && (rawStr.toLowerCase() === 'true' || rawStr.toLowerCase() === 'false')) {
+ valueLabel = t(rawStr.toLowerCase() === 'true' ? 'boolOn' : 'boolOff');
+ }
+ if (!valueLabel) valueLabel = opts ? String(opts[choice] ?? choice) : rawStr;
out.push({ valueLabel, keys });
}
return out;
@@ -167,11 +173,15 @@
* · 静态组(configGroups):字段按组归类,组内可嵌套子静态组;
* · 条件组(field.type.sub_configs):勾选/选中父字段某值才显示的受控字段,
* 组头 = 父字段显示名 +「显隐组」徽标,规则行 =「值 → N 项」;
- * · 嵌套:受控字段自己也有 sub_configs → 在其规则下递归缩进(深度上限 4 防环);
- * · 同一字段既在静态组又被条件组受控 → 两处都渲染(一视同仁);
+ * · 嵌套拍平:条件组最多嵌套 COND_NEST_CAP 层,第 3 层起不再建嵌套组卡,
+ * 字段行行尾挂「条件摘要徽标」(开→排轴序列 …)——ok-end-field 实测最深
+ * 4 节点链(使用独立配置→自动技能列表→启用排轴→排轴序列),全展开会把
+ * 250px 弹层压扁;同字段既在静态组又被条件组受控 → 两处都渲染(一视同仁);
* · 未归属任何组/条件的字段 → 「其他参数」。
* 归并规则与 configPanel 对齐:分组名自身的显隐规则子项吸收进该组 children。
*/
+ const COND_NEST_CAP = 2;
+
function groupPopContent(task, schema) {
const fields = schema?.fields || [];
if (!fields.length) return null;
@@ -220,18 +230,29 @@
title.appendChild(sub);
frag.appendChild(title);
+ /** 拍平徽标:该字段自身条件规则的摘要文本(值→受控字段显示名…;多规则分号连接) */
+ function condBadgeText(rules) {
+ return rules.map((rule) => `${rule.valueLabel}→${rule.keys.map(labelOf).join('、')}`).join(';');
+ }
+
+ /** depth = 该字段已被嵌套在几层条件组里;到 cap 不再建嵌套组,改为行尾摘要徽标 */
function renderItem(key, container, depth) {
const it = document.createElement('div');
it.className = 'it';
const label = document.createElement('span');
label.textContent = labelOf(key);
it.appendChild(label);
- container.appendChild(it);
const f = fieldsByKey[key];
- if (f && depth < 4) {
- const rules = condRulesOf(f);
- if (rules.length) appendCondGroup(f, rules, container, depth + 1);
+ const rules = f ? condRulesOf(f) : [];
+ if (rules.length && depth >= COND_NEST_CAP) {
+ it.classList.add('it--flat');
+ const badge = document.createElement('span');
+ badge.className = 'it__cond';
+ badge.textContent = condBadgeText(rules);
+ it.appendChild(badge);
}
+ container.appendChild(it);
+ if (rules.length && depth < COND_NEST_CAP) appendCondGroup(f, rules, container, depth);
}
/** 一个父字段 = 一个条件组;组内每条规则 = 规则行(值 → N 项)+ 受控字段缩进列表 */
@@ -260,14 +281,14 @@
body.appendChild(ruleRow);
const list = document.createElement('div');
list.className = 'grp-body';
- for (const key of rule.keys) renderItem(key, list, depth);
+ for (const key of rule.keys) renderItem(key, list, depth + 1);
body.appendChild(list);
}
grp.append(head, body);
container.appendChild(grp);
}
- function appendStaticGroup(gkey, container, depth) {
+ function appendStaticGroup(gkey, container, depth, seen) {
const children = groupMap.get(gkey) || [];
const grp = document.createElement('div');
grp.className = 'grp';
@@ -282,18 +303,23 @@
const body = document.createElement('div');
body.className = 'grp-body';
for (const key of children) {
- if (groupMap.has(key)) appendStaticGroup(key, body, depth + 1); // 静态组嵌静态组
- else renderItem(key, body, depth);
+ // 静态组嵌静态组;seen 沿递归路径防环(如「多账户模式」组 children 含自己)
+ if (groupMap.has(key)) {
+ if (!seen.has(key)) appendStaticGroup(key, body, depth + 1, new Set([...seen, key]));
+ } else renderItem(key, body, depth);
}
grp.append(head, body);
container.appendChild(grp);
}
- // 静态组(嵌套子组随父组渲染,不重复出现在顶层)
+ // 静态组(嵌套子组随父组渲染,不重复出现在顶层;
+ // 自引用不算被嵌套——ok-end-field 的「多账户模式」组 children 就是它自己)
const nested = new Set();
- for (const children of groupMap.values()) for (const key of children) if (groupMap.has(key)) nested.add(key);
+ for (const [gkey, children] of groupMap) {
+ for (const key of children) if (groupMap.has(key) && key !== gkey) nested.add(key);
+ }
for (const gkey of groupMap.keys()) {
- if (!nested.has(gkey)) appendStaticGroup(gkey, frag, 0);
+ if (!nested.has(gkey)) appendStaticGroup(gkey, frag, 0, new Set([gkey]));
}
// 条件组(与静态组同级,一视同仁;一个父字段一个组)
for (const { field, rules } of condParents) {
From b1bfd031745a95fa1e55cdeab847b39038ff2043 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 04:28:11 +0800
Subject: [PATCH 10/12] fix(console): popup dedupes fields - groups absorb
visibility chains, one render per field
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Aligns popup ownership with the drawer config panel semantics:
- a field renders EXACTLY ONCE per popup (rendered set): static groups first,
then root condition groups, leftovers fall to Other Parameters
- only ROOT visibility sources (not group members, not controlled fields)
open a top-level condition group; controlled sources (启用排轴/自动技能列表)
expand their children in place where they are controlled
- a condition group whose controlled fields are all absorbed by static groups
is skipped entirely (配置选择), its parent falls back to a plain row
- self-referencing group (多账户模式 children=[itself]) now renders its field
row instead of vanishing
- real-data CDP audit: BattleTask cond group heads 11 -> 2, all 4 scenarios
report zero duplicated field rows
---
media/console/taskCard.js | 83 ++++++++++++++++++++++++++++-----------
1 file changed, 59 insertions(+), 24 deletions(-)
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 0e1a629..835b09a 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -169,16 +169,19 @@
}
/**
- * 弹层内容:参数分组概要 —— 两种组一视同仁渲染,可互相嵌套:
- * · 静态组(configGroups):字段按组归类,组内可嵌套子静态组;
- * · 条件组(field.type.sub_configs):勾选/选中父字段某值才显示的受控字段,
+ * 弹层内容:参数分组概要 —— 与抽屉参数面板同一归属语义(分组吸收显隐,无重复):
+ * · 静态组(configGroups):字段按组归类;组名与组成员自身带 sub_configs →
+ * 子项随字段在组内展开(吸收显隐),不开独立条件组;
+ * · 条件组(field.type.sub_configs):只给「不归属任何组的根显隐源」开组,
* 组头 = 父字段显示名 +「显隐组」徽标,规则行 =「值 → N 项」;
* · 嵌套拍平:条件组最多嵌套 COND_NEST_CAP 层,第 3 层起不再建嵌套组卡,
* 字段行行尾挂「条件摘要徽标」(开→排轴序列 …)——ok-end-field 实测最深
* 4 节点链(使用独立配置→自动技能列表→启用排轴→排轴序列),全展开会把
- * 250px 弹层压扁;同字段既在静态组又被条件组受控 → 两处都渲染(一视同仁);
- * · 未归属任何组/条件的字段 → 「其他参数」。
- * 归并规则与 configPanel 对齐:分组名自身的显隐规则子项吸收进该组 children。
+ * 250px 弹层压扁;
+ * · 去重:一个字段全弹层只渲染一次(rendered 集合)。静态组先渲染,
+ * 条件组受控字段若已归属静态组 → 该行跳过;受控字段全部已归属 →
+ * 整个条件组跳过,父字段落回「其他参数」当普通行(如 BattleTask 的「配置选择」);
+ * · 未归属任何组/条件/受控链的字段 → 「其他参数」。
*/
const COND_NEST_CAP = 2;
@@ -202,23 +205,42 @@
groupMap.set(gkey, children);
}
- // 条件组父字段(分组名除外)与受控字段集合
+ const groupChildren = new Set();
+ for (const children of groupMap.values()) for (const key of children) groupChildren.add(key);
+
+ // 递归收集所有显隐受控字段(防环)——被控制的字段不再自己开条件组,
+ // 其子项在受控它的位置展开(分组吸收显隐,对齐抽屉参数面板的归属语义)
+ const controlledAll = new Set();
+ const collectControlled = (key, seen) => {
+ const f = fieldsByKey[key];
+ for (const rule of condRulesOf(f)) {
+ for (const k of rule.keys) {
+ if (!fieldsByKey[k] || seen.has(k)) continue;
+ if (!controlledAll.has(k)) {
+ controlledAll.add(k);
+ collectControlled(k, new Set([...seen, k]));
+ }
+ }
+ }
+ };
+ for (const f of fields) {
+ if (condRulesOf(f).length) collectControlled(f.key, new Set([f.key]));
+ }
+
+ // 条件组父字段 = 不归属任何组的「根显隐源」:
+ // 组名源 → 子项已吸收进组 children;组成员源 → 子项随字段在组内展开
const condParents = [];
- const controlled = new Set();
for (const f of fields) {
- if (groupMap.has(f.key)) continue;
+ if (groupMap.has(f.key) || groupChildren.has(f.key)) continue;
const rules = condRulesOf(f);
- if (rules.length) {
- condParents.push({ field: f, rules });
- for (const rule of rules) for (const key of rule.keys) controlled.add(key);
- }
+ if (rules.length) condParents.push({ field: f, rules });
}
- const groupChildren = new Set();
- for (const children of groupMap.values()) for (const key of children) groupChildren.add(key);
- const others = fields.filter((f) => !groupMap.has(f.key) && !groupChildren.has(f.key) && !controlled.has(f.key));
+ const orphanCount = fields.filter((f) => !groupMap.has(f.key) && !groupChildren.has(f.key)
+ && !controlledAll.has(f.key) && !condParents.some((p) => p.field.key === f.key)).length;
+ if (!groupMap.size && !condParents.length && !orphanCount) return null;
- if (!groupMap.size && !condParents.length && !others.length) return null;
+ const rendered = new Set(); // 全弹层去重:一个字段只渲染一次
const frag = document.createDocumentFragment();
const title = document.createElement('div');
@@ -226,7 +248,7 @@
title.textContent = schema?.displayName || task.displayName || '';
const sub = document.createElement('span');
sub.className = 'pop-sub';
- sub.textContent = t('depsPopSub', { count: groupMap.size + condParents.length + (others.length ? 1 : 0) });
+ sub.textContent = t('depsPopSub', { count: groupMap.size + condParents.length + (orphanCount ? 1 : 0) });
title.appendChild(sub);
frag.appendChild(title);
@@ -237,6 +259,8 @@
/** depth = 该字段已被嵌套在几层条件组里;到 cap 不再建嵌套组,改为行尾摘要徽标 */
function renderItem(key, container, depth) {
+ if (rendered.has(key)) return; // 已被静态组/其他位置吸收 → 不重复
+ rendered.add(key);
const it = document.createElement('div');
it.className = 'it';
const label = document.createElement('span');
@@ -255,8 +279,12 @@
if (rules.length && depth < COND_NEST_CAP) appendCondGroup(f, rules, container, depth);
}
- /** 一个父字段 = 一个条件组;组内每条规则 = 规则行(值 → N 项)+ 受控字段缩进列表 */
+ /** 一个父字段 = 一个条件组;组内每条规则 = 规则行(值 → N 项)+ 受控字段缩进列表。
+ * 受控字段若已全部被静态组吸收渲染 → 整组跳过(返回 false,父字段落回其他参数) */
function appendCondGroup(parentField, rules, container, depth) {
+ const hasPending = rules.some((rule) => rule.keys.some((k) => fieldsByKey[k] && !rendered.has(k)));
+ if (!hasPending) return false;
+ rendered.add(parentField.key);
const grp = document.createElement('div');
grp.className = 'grp grp--cond';
const head = document.createElement('div');
@@ -286,6 +314,7 @@
}
grp.append(head, body);
container.appendChild(grp);
+ return true;
}
function appendStaticGroup(gkey, container, depth, seen) {
@@ -303,9 +332,11 @@
const body = document.createElement('div');
body.className = 'grp-body';
for (const key of children) {
- // 静态组嵌静态组;seen 沿递归路径防环(如「多账户模式」组 children 含自己)
+ // 静态组嵌静态组;seen 沿递归路径防环(「多账户模式」组 children 含自己:
+ // 跳过递归但渲染字段行,字段本体仍可见且只出现一次)
if (groupMap.has(key)) {
- if (!seen.has(key)) appendStaticGroup(key, body, depth + 1, new Set([...seen, key]));
+ if (seen.has(key)) renderItem(key, body, depth);
+ else appendStaticGroup(key, body, depth + 1, new Set([...seen, key]));
} else renderItem(key, body, depth);
}
grp.append(head, body);
@@ -321,11 +352,15 @@
for (const gkey of groupMap.keys()) {
if (!nested.has(gkey)) appendStaticGroup(gkey, frag, 0, new Set([gkey]));
}
- // 条件组(与静态组同级,一视同仁;一个父字段一个组)
+ // 条件组(只给根显隐源开组;受控字段已全部归属静态组 → 整组跳过,
+ // 父字段落回「其他参数」当普通行,如 BattleTask 的「配置选择」)
+ const skippedCondParents = [];
for (const { field, rules } of condParents) {
- appendCondGroup(field, rules, frag, 0);
+ if (!appendCondGroup(field, rules, frag, 0)) skippedCondParents.push(field);
}
- // 其他参数(无静态组时的「参数」伪组沿用旧语义标签)
+ // 其他参数:还没渲染过的剩余字段(非组名、非根条件源)+ 被整组跳过的条件源
+ const others = fields.filter((f) => !rendered.has(f.key) && !groupMap.has(f.key)
+ && (!condParents.some((p) => p.field.key === f.key) || skippedCondParents.includes(f)));
if (others.length) {
const grp = document.createElement('div');
grp.className = 'grp';
From 109a70b7ae0247465f94766d7ce724a005e0f783 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:36:10 +0800
Subject: [PATCH 11/12] feat(console): hover parameter popup for global config
cards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- extract bindHoverPop from task card wiring and reuse it on the config
segment's global group cards (fake task/schema share the same field
contract: displayKey/default/type.sub_configs), so hovering a card like
Battle Config shows the same grouped popup as task cards
- fix cond-root ownership in groupPopContent: condParents now also excludes
fields that sit downstream of any visibility chain (controlledAll). In the
global Battle Config group, fields order puts controlled sources (启用排轴/
启用实时条件) BEFORE the root 自动技能列表, so they used to open their own
top-level groups and severed the chain; now only true roots open groups
and controlled sources expand in place as nested groups
- renderConfig now hides the stale popup (hideHoverPop(true)) when cards are
rebuilt; suppressPopups was wrong there - its 1.2s suppression window also
swallowed task-card popups during initial load (globalGroups message
arrives before tasks, caught by CDP scenario)
- CDP audit on real ok-end-field data: config-card popup condHeads exactly
[自动技能列表, 启用排轴, 启用实时条件] (single root, nested ownership),
zero duplicated rows, zero 3-level nesting across all 5 scenarios
---
media/console/console.js | 7 +++++++
media/console/taskCard.js | 27 ++++++++++++++++++---------
2 files changed, 25 insertions(+), 9 deletions(-)
diff --git a/media/console/console.js b/media/console/console.js
index 2f9c5ba..e839f22 100644
--- a/media/console/console.js
+++ b/media/console/console.js
@@ -52,6 +52,10 @@
}
function renderConfig(groups, snapshots, expanded) {
+ // 折叠切换/快照刷新会重建全部配置卡:旧卡被移除后悬停弹层成了幽灵,先收掉。
+ // 不用 suppressPopups——那会上 1.2s 抑制窗口,初始加载时 globalGroups 先于
+ // tasks 到达会把任务卡的悬停弹出一起闷掉(CDP 场景实测踩中)。
+ globalThis.TaskLauncherTaskCard?.hideHoverPop?.(true);
state.globalGroups = groups || [];
state.globalSnapshots = snapshots || {};
state.expandedGlobalGroups = Array.isArray(expanded) ? expanded : [];
@@ -185,6 +189,9 @@
}
card.append(head, body);
+ // 悬停参数概要弹层(与任务卡同款):全局组字段与任务 schema 同构,直接复用
+ // groupPopContent —— 条件链按「分组吸收显隐」归属,深层拍平徽标。
+ globalThis.TaskLauncherTaskCard?.bindHoverPop?.(card, fakeTask, fakeSchema);
return card;
}
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 835b09a..1f65571 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -227,11 +227,13 @@
if (condRulesOf(f).length) collectControlled(f.key, new Set([f.key]));
}
- // 条件组父字段 = 不归属任何组的「根显隐源」:
- // 组名源 → 子项已吸收进组 children;组成员源 → 子项随字段在组内展开
+ // 条件组父字段 = 「根显隐源」:不归属任何组、也不在任何控制链下游。
+ // 被控字段(如全局 Battle Config 的启用排轴——fields 顺序排在根源
+ // 自动技能列表之前)绝不能自己开顶层组,否则链从属关系断裂;
+ // 其子项在受控它的位置展开(分组吸收显隐,对齐抽屉参数面板语义)
const condParents = [];
for (const f of fields) {
- if (groupMap.has(f.key) || groupChildren.has(f.key)) continue;
+ if (groupMap.has(f.key) || groupChildren.has(f.key) || controlledAll.has(f.key)) continue;
const rules = condRulesOf(f);
if (rules.length) condParents.push({ field: f, rules });
}
@@ -493,14 +495,21 @@
const configPanel = buildConfigPanel(task, schema);
card.append(header, configPanel);
// 悬停弹出:有分组层次按组展示,没层次但有字段折叠成「参数」伪组(见 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());
- }
+ bindHoverPop(card, task, schema);
return card;
}
+ /** 卡片悬停弹层绑定(任务卡与配置分段全局组卡共用):有字段才弹 + hover 边框提示 */
+ function bindHoverPop(card, task, schema) {
+ const hasContent = (schema?.configGroups && Object.keys(schema.configGroups).length)
+ || Boolean(schema?.fields?.length);
+ if (!hasContent) return false;
+ card.classList.add('task-card--pop');
+ card.addEventListener('mouseenter', () => showHoverPop(card, task, schema));
+ card.addEventListener('mouseleave', () => hideHoverPop());
+ return true;
+ }
+
/** 单个卡片的执行状态:触发任务看入列/轮询,一次性任务看排队/执行 */
function cardState(card) {
const executor = state.executor;
@@ -752,5 +761,5 @@
renderTasks(state.currentTasks);
}
- globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup, suppressPopups };
+ globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup, suppressPopups, hideHoverPop, bindHoverPop };
})();
From 7fb103059782a2512c1beb5c197c445d9132a6a5 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Fri, 25 Sep 2026 00:23:42 +0800
Subject: [PATCH 12/12] fix(console): address CodeRabbit review - dedupe
renderHealth call, count owned tasks from data not DOM, drop aria-live on
rebuilt strips, clamp popover left edge to main pane, keyboard-accessible
hover popup (focusin/aria-describedby)
---
media/console/app.js | 1 -
media/console/console.js | 8 +++++++-
media/console/index.html | 4 ++--
media/console/taskCard.js | 41 +++++++++++++++++++++++++++++++++------
4 files changed, 44 insertions(+), 10 deletions(-)
diff --git a/media/console/app.js b/media/console/app.js
index d852a23..4fc2c1b 100644
--- a/media/console/app.js
+++ b/media/console/app.js
@@ -59,7 +59,6 @@
renderTasks(state.currentTasks);
Console.refreshDrawer();
Console.renderHealth();
- Console.renderHealth();
} else if (message.target === 'global') {
state.globalSnapshots[message.name] = message.values || {};
Console.renderConfig(state.globalGroups, state.globalSnapshots, state.expandedGlobalGroups || []);
diff --git a/media/console/console.js b/media/console/console.js
index e839f22..84ee169 100644
--- a/media/console/console.js
+++ b/media/console/console.js
@@ -89,7 +89,13 @@
}
let fieldCount = 0;
for (const group of groups) fieldCount += (group.fields || []).length;
- const owned = document.querySelectorAll('[data-role="config-toggle"].has-overrides').length;
+ // 已定制任务按数据计数(state.currentTasks + 快照≠出厂),不依赖卡片 DOM——
+ // 搜索过滤/分组折叠会改变可见卡片数,DOM 计数会跟着漂移(CodeRabbit review)
+ const listed = (state.currentTasks || [])
+ .filter((task) => state.schemas[taskKey(task)]?.showInTaskTab !== false);
+ const owned = listed
+ .filter((task) => globalThis.TaskLauncherTaskCard.snapshotDiffersFromFactory(taskKey(task)))
+ .length;
strip.textContent = '';
const head = document.createElement('div');
head.className = 'rc-health__head';
diff --git a/media/console/index.html b/media/console/index.html
index c6085f2..30be6c2 100644
--- a/media/console/index.html
+++ b/media/console/index.html
@@ -60,7 +60,7 @@
-
+
-
+
diff --git a/media/console/taskCard.js b/media/console/taskCard.js
index 1f65571..3774d1e 100644
--- a/media/console/taskCard.js
+++ b/media/console/taskCard.js
@@ -134,6 +134,7 @@
let popEl = null;
let popHideTimer = 0;
let popSuppressUntil = 0;
+ let popFocusCard = null; // 焦点所在的弹层宿主卡(键盘可达性:焦点在卡内时弹层保持)
/**
* field.type.sub_configs → [{ valueLabel, keys }](对齐 configPanel 的规则归一):
@@ -402,9 +403,14 @@
if (!popEl) {
popEl = document.createElement('div');
popEl.className = 'gpop';
+ popEl.id = 'gpop-tooltip'; // 焦点元素的 aria-describedby 指向这里(键盘可达性)
popEl.setAttribute('role', 'tooltip');
popEl.addEventListener('mouseenter', () => window.clearTimeout(popHideTimer));
- popEl.addEventListener('mouseleave', () => hideHoverPop());
+ popEl.addEventListener('mouseleave', () => {
+ // 焦点仍留在宿主卡内(键盘用户 Tab 进来的)时,指针离开弹层不隐藏
+ if (popFocusCard && popFocusCard.contains(document.activeElement)) return;
+ hideHoverPop();
+ });
document.body.appendChild(popEl);
}
popEl.replaceChildren(content);
@@ -412,14 +418,17 @@
const rect = card.getBoundingClientRect();
const vh = window.innerHeight;
// 恒定左侧展开(用户要求:不回退右侧)。空间不足先收窄宽度(下限 180px),
- // 连下限都放不下就贴视口左缘(left 钳到 8),允许少量压住卡片——悬停即走,无碍。
+ // 连下限都放不下就贴主栏左缘——左界钳在 .lay-main 内 +8px(找不到主栏退回
+ // 视口 8px),弹层不允许盖住侧栏;再不够就允许压住卡片,悬停即走无碍。
const POP_W = 250; // 与 console.css .gpop 的 width 保持一致
- const availLeft = rect.left - 16; // 卡片间隙 8px + 视口边距 8px
+ const main = card.closest('.lay-main');
+ const minLeft = main ? Math.max(8, main.getBoundingClientRect().left + 8) : 8;
+ const availLeft = rect.left - 8 - minLeft; // 卡片间隙 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,垂直顶对齐卡片——向下/向右弹都会盖住别的任务卡
- const left = Math.max(8, rect.left - pw - 8);
+ const left = Math.max(minLeft, 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`;
@@ -506,7 +515,27 @@
if (!hasContent) return false;
card.classList.add('task-card--pop');
card.addEventListener('mouseenter', () => showHoverPop(card, task, schema));
- card.addEventListener('mouseleave', () => hideHoverPop());
+ card.addEventListener('mouseleave', () => {
+ // 键盘可达性:焦点仍在卡内(Tab 进来的)时,指针离开不隐藏
+ if (popFocusCard === card && card.contains(document.activeElement)) return;
+ hideHoverPop();
+ });
+ // 键盘可达性(CodeRabbit review):focusin 显示弹层,并把焦点元素用
+ // aria-describedby 关联到弹层(role=tooltip);focusout 焦点真正离开卡片才解除
+ card.addEventListener('focusin', (event) => {
+ popFocusCard = card;
+ showHoverPop(card, task, schema);
+ if (popEl && event.target instanceof Element) {
+ event.target.setAttribute('aria-describedby', popEl.id);
+ }
+ });
+ card.addEventListener('focusout', (event) => {
+ if (event.target instanceof Element) event.target.removeAttribute('aria-describedby');
+ const next = event.relatedTarget;
+ if (next instanceof Node && card.contains(next)) return; // 焦点在卡内移动
+ if (popFocusCard === card) popFocusCard = null;
+ hideHoverPop();
+ });
return true;
}
@@ -761,5 +790,5 @@
renderTasks(state.currentTasks);
}
- globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup, suppressPopups, hideHoverPop, bindHoverPop };
+ globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup, suppressPopups, hideHoverPop, bindHoverPop, snapshotDiffersFromFactory };
})();