From c8f9d8444048ce5ba92b5ab9767d72f16a2ec3bb Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:05:27 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(console):=20=E4=B8=BB=E4=BB=8E?= =?UTF-8?q?=E5=8F=8C=E6=A0=8F=E5=B8=83=E5=B1=80=20+=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E5=BA=A6=E6=9D=A1=20+=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E9=98=9F=E5=88=97=E6=9D=A1=20+=20=E4=BB=BB=E5=8A=A1=E5=8D=A1?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=81=AF/=E5=8F=82=E6=95=B0=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=82=AC=E5=81=9C=E5=BC=B9=E5=87=BA=EF=BC=88=E5=85=AD?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- media/console/app.js | 5 + media/console/console.css | 188 ++++++++++++++++++++++++++++++++++++++ media/console/console.js | 82 ++++++++++++++--- media/console/index.html | 35 +++++-- media/console/taskCard.js | 95 ++++++++++++++++++- src/localization.ts | 42 +++++++++ 6 files changed, 421 insertions(+), 26 deletions(-) diff --git a/media/console/app.js b/media/console/app.js index 46ea416..d852a23 100644 --- a/media/console/app.js +++ b/media/console/app.js @@ -21,6 +21,7 @@ state.schemas = message.schemas || state.schemas; renderTasks(message.tasks || []); Console.refreshDrawer(); + Console.renderHealth(); // 多账户存储概要随 tasks 消息一起到达(probe 探测结果);store 数据保持独立状态 Console.renderMultiAccount(message.multiAccount || state.multiAccount, state.accountStore); break; @@ -31,6 +32,7 @@ Console.renderMultiAccount(message.multiAccount || state.multiAccount, state.accountStore); renderTasks(state.currentTasks); Console.refreshDrawer(); + Console.renderHealth(); break; case 'taskConfigs': state.taskConfigs = message.configs || {}; @@ -38,6 +40,7 @@ if (message.uiState) state.uiState = message.uiState; renderTasks(state.currentTasks); Console.refreshDrawer(); + Console.renderHealth(); break; // 配置接管:全局配置组 + 快照(配置分段数据源) case 'globalGroups': @@ -55,6 +58,8 @@ }; 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.css b/media/console/console.css index 783e492..838157b 100644 --- a/media/console/console.css +++ b/media/console/console.css @@ -1098,6 +1098,194 @@ button.secondary:hover:not(:disabled) { background: var(--vscode-button-secondar .config-section-title--toggle { cursor: pointer; user-select: none; } .config-section-title__chev { display: inline-block; width: 11px; margin-right: 2px; color: var(--muted); } +/* ═══ 主从双栏 shell(侧栏导航 + 主内容) ═════════════════════ */ +.shell { display: flex; gap: 10px; align-items: stretch; } +.lay-side { + position: sticky; + top: 0; + align-self: flex-start; + display: flex; + flex-direction: column; + gap: 2px; + width: 158px; + flex: 0 0 auto; + max-height: 100vh; + padding: 6px 6px 6px 0; + border-right: 1px solid var(--border); + background: var(--vscode-editor-background); +} +.lay-side__item { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-radius: var(--radius-sm); + color: var(--muted); + font-size: 13px; + cursor: pointer; + user-select: none; +} +.lay-side__item:hover { background: var(--surface-hover); color: var(--vscode-editor-foreground); } +.lay-side__item.is-active { + background: var(--surface-hover); + color: var(--vscode-editor-foreground); + box-shadow: inset 2px 0 0 var(--border-strong); +} +.lay-side__ico { + width: 14px; height: 14px; + flex: 0 0 auto; + border-radius: 4px; + background: var(--border-strong); + opacity: .7; +} +.lay-side__item.is-active .lay-side__ico { opacity: 1; } +.lay-side__foot { margin-top: auto; padding: 10px 12px; font-size: 11px; color: var(--muted); } +.lay-main { flex: 1; min-width: 0; } + +/* ═══ 配置健康度条(任务分段顶部,真实快照数据) ══════════════ */ +.rc-health { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; + margin: 0 0 10px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} +.rc-health__head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; flex: 1; min-width: 0; } +.rc-health__title { font-size: 12px; font-weight: 700; } +.rc-health__cells { display: flex; gap: 8px; flex: 1; min-width: 0; } +.rc-cell { flex: 1; min-width: 96px; padding: 6px 12px; border-radius: var(--radius-sm); background: var(--surface-hover); } +.rc-cell__label { font-size: 10px; color: var(--muted); } +.rc-cell__value { font-size: 16px; font-weight: 700; } +.rc-cell.is-ok .rc-cell__value { color: var(--ok); } +.rc-cell.is-warn .rc-cell__value { color: var(--warn); } + +/* ═══ 执行队列条(一次性队列可视化) ══════════════════════════ */ +.rc-queue { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 10px; + padding: 8px 12px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface); +} +.rc-queue__head { display: flex; align-items: baseline; gap: 8px; } +.rc-queue__head b { font-size: 12px; } +.rc-queue__chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 1; min-width: 0; } +.rc-queue__chip { + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-hover); + font-size: 11px; +} + +/* ═══ 运行状态灯(任务卡标题行) ══════════════════════════════ */ +.rc-dot { + flex: 0 0 auto; + width: 8px; height: 8px; + border-radius: 50%; + background: color-mix(in srgb, var(--muted) 55%, transparent); +} +.task-card[data-run="running"] .rc-dot, +.task-card[data-run="polling"] .rc-dot { + background: var(--run); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--run) 18%, transparent); + animation: gbar-pulse 1.8s ease-in-out infinite; +} +.task-card[data-run="queued"] .rc-dot, +.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; + width: 250px; + padding: 10px 12px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: color-mix(in srgb, var(--surface-raised, var(--surface)) 82%, 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); + pointer-events: none; + transition: opacity .18s ease, transform .18s cubic-bezier(.2,.9,.3,1.2); + transition-delay: 0s; +} +.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 { + display: flex; align-items: baseline; justify-content: space-between; + margin-bottom: 2px; font-size: 12px; font-weight: 700; +} +.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 { + 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 { + 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 { + 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 { + position: relative; + display: flex; align-items: center; justify-content: space-between; gap: 8px; + 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 { + 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); } + + /* hidden 属性兜底:作者样式里的 display(.config-fields 的 grid、卡片 body 的 flex 等) 会覆盖 UA 的 [hidden]{display:none},导致所有基于 hidden 的折叠/显隐在真机上失效 diff --git a/media/console/console.js b/media/console/console.js index 5c544cb..952748b 100644 --- a/media/console/console.js +++ b/media/console/console.js @@ -16,10 +16,10 @@ // ── 分段切换 ───────────────────────────────────────────────────────── function switchSeg(seg) { - for (const button of document.querySelectorAll('.seg__btn')) { - const active = button.dataset.seg === seg; - button.classList.toggle('is-active', active); - button.setAttribute('aria-selected', active ? 'true' : 'false'); + for (const item of document.querySelectorAll('.lay-side__item')) { + const active = item.dataset.seg === seg; + item.classList.toggle('is-active', active); + item.setAttribute('aria-selected', active ? 'true' : 'false'); } $('pageTasks').hidden = seg !== 'tasks'; $('pageGame').hidden = seg !== 'game'; @@ -55,11 +55,56 @@ empty.className = 'config-empty'; empty.textContent = t('noConfigParameters'); host.appendChild(empty); + renderHealth(); return; } for (const group of state.globalGroups) { host.appendChild(buildGlobalCard(group, state.expandedGlobalGroups.includes(group.name))); } + renderHealth(); + } + + /** 配置健康度条:快照组 / 快照字段 / 已定制任务(全部来自既有真实数据) */ + function renderHealth() { + const strip = $('healthStrip'); + if (!strip) return; + const groups = state.globalGroups || []; + if (!groups.length) { + strip.hidden = true; + strip.textContent = ''; + return; + } + let fieldCount = 0; + for (const group of groups) fieldCount += (group.fields || []).length; + const owned = document.querySelectorAll('[data-role="config-toggle"].has-overrides').length; + strip.textContent = ''; + const head = document.createElement('div'); + head.className = 'rc-health__head'; + const title = document.createElement('span'); + title.className = 'rc-health__title'; + title.textContent = t('healthTitle'); + const cells = document.createElement('div'); + cells.className = 'rc-health__cells'; + const defs = [ + ['healthGroups', groups.length, 'ok'], + ['healthFields', fieldCount, 'ok'], + ['healthOwned', owned, owned > 0 ? 'warn' : 'ok'], + ]; + for (const [key, value, tone] of defs) { + const cell = document.createElement('div'); + cell.className = `rc-cell is-${tone}`; + const label = document.createElement('div'); + label.className = 'rc-cell__label'; + label.textContent = t(key); + const num = document.createElement('div'); + num.className = 'rc-cell__value'; + num.textContent = String(value); + cell.append(label, num); + cells.appendChild(cell); + } + head.append(title, cells); + strip.append(head); + strip.hidden = false; } function buildGlobalCard(group, expanded) { @@ -638,15 +683,25 @@ function init() { document.title = t('consoleTitle'); - // 分段导航 - $('segTasks').textContent = t('consoleTabTasks'); - $('segGame').textContent = t('consoleTabGame'); - $('segConfig').textContent = t('consoleTabConfig'); - $('segAccounts').textContent = t('consoleTabAccounts'); - $('segTasks').addEventListener('click', () => switchSeg('tasks')); - $('segGame').addEventListener('click', () => switchSeg('game')); - $('segConfig').addEventListener('click', () => switchSeg('config')); - $('segAccounts').addEventListener('click', () => switchSeg('accounts')); + // 侧栏导航(主从双栏):文案 + 点击/键盘切换 + const sideTabs = [ + ['segTasks', 'consoleTabTasks'], + ['segGame', 'consoleTabGame'], + ['segConfig', 'consoleTabConfig'], + ['segAccounts', 'consoleTabAccounts'], + ]; + for (const [id, key] of sideTabs) { + const item = $(id); + item.querySelector('.lay-side__label').textContent = t(key); + item.addEventListener('click', () => switchSeg(item.dataset.seg)); + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + switchSeg(item.dataset.seg); + } + }); + } + $('sideFoot').textContent = t('consoleTitle'); // 任务分段 const search = $('taskSearch'); @@ -787,6 +842,7 @@ closeDrawer, refreshDrawer, renderConfig, + renderHealth, renderMultiAccount, }; })(); diff --git a/media/console/index.html b/media/console/index.html index d9cb3a8..c6085f2 100644 --- a/media/console/index.html +++ b/media/console/index.html @@ -9,6 +9,26 @@
+
+ + + + +
@@ -38,16 +58,9 @@ - - - - +
+
+ +
@@ -117,6 +132,8 @@
+
+
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 }; })();