diff --git a/media/console/app.js b/media/console/app.js index 46ea416..4fc2c1b 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,7 @@ }; renderTasks(state.currentTasks); Console.refreshDrawer(); + 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..fa15be8 100644 --- a/media/console/console.css +++ b/media/console/console.css @@ -1098,6 +1098,229 @@ 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; } + +/* ═══ 任务卡悬停弹出: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)) 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; + visibility: hidden; + transform: translateY(4px) scale(.98); + pointer-events: none; + transition: opacity .16s ease, transform .16s cubic-bezier(.2,.9,.3,1.2), visibility 0s linear .16s; +} +.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; +} +/* 弹层内部滚动条(内容超高时全量翻阅) */ +.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; } +/* 条件组(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; + margin-bottom: 2px; font-size: 12px; font-weight: 700; + animation: popfade .24s ease both; +} +.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); +} +.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; +} +.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); +} +.gpop .it { + position: relative; + 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; +} +.gpop .it::before { + content: ''; + position: absolute; + top: 50%; left: -12px; + width: 10px; height: 1px; + 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; } +@keyframes pagefade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } + + /* 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..84ee169 100644 --- a/media/console/console.js +++ b/media/console/console.js @@ -16,15 +16,24 @@ // ── 分段切换 ───────────────────────────────────────────────────────── 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'); + // 弹出层规范:切 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); + item.setAttribute('aria-selected', active ? 'true' : 'false'); } $('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__::组名) ── @@ -43,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 : []; @@ -55,11 +68,62 @@ 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; + // 已定制任务按数据计数(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'; + 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) { @@ -131,6 +195,9 @@ } card.append(head, body); + // 悬停参数概要弹层(与任务卡同款):全局组字段与任务 schema 同构,直接复用 + // groupPopContent —— 条件链按「分组吸收显隐」归属,深层拍平徽标。 + globalThis.TaskLauncherTaskCard?.bindHoverPop?.(card, fakeTask, fakeSchema); return card; } @@ -638,15 +705,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 +864,7 @@ closeDrawer, refreshDrawer, renderConfig, + renderHealth, renderMultiAccount, }; })(); diff --git a/media/console/index.html b/media/console/index.html index d9cb3a8..30be6c2 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..3774d1e 100644 --- a/media/console/taskCard.js +++ b/media/console/taskCard.js @@ -126,6 +126,330 @@ return [sync, reset]; } + /* ── 悬停弹出(body 级单例 + fixed 定位)────────────────────────── + 卡内 absolute + left:calc(100%+12px) 会被任务列表滚动容器的 + overflow 裁掉(lab 里 B/C 布局同款翻车),挂到 body 上用 + getBoundingClientRect + 视口夹紧定位,右缘对齐卡片、优先下方、 + 底部放不下自动翻上方。只读无按钮;内容与 configPanel 同源。 */ + let popEl = null; + let popHideTimer = 0; + let popSuppressUntil = 0; + let popFocusCard = null; // 焦点所在的弹层宿主卡(键盘可达性:焦点在卡内时弹层保持) + + /** + * 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]; + 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; + } + + /** + * 弹层内容:参数分组概要 —— 与抽屉参数面板同一归属语义(分组吸收显隐,无重复): + * · 静态组(configGroups):字段按组归类;组名与组成员自身带 sub_configs → + * 子项随字段在组内展开(吸收显隐),不开独立条件组; + * · 条件组(field.type.sub_configs):只给「不归属任何组的根显隐源」开组, + * 组头 = 父字段显示名 +「显隐组」徽标,规则行 =「值 → N 项」; + * · 嵌套拍平:条件组最多嵌套 COND_NEST_CAP 层,第 3 层起不再建嵌套组卡, + * 字段行行尾挂「条件摘要徽标」(开→排轴序列 …)——ok-end-field 实测最深 + * 4 节点链(使用独立配置→自动技能列表→启用排轴→排轴序列),全展开会把 + * 250px 弹层压扁; + * · 去重:一个字段全弹层只渲染一次(rendered 集合)。静态组先渲染, + * 条件组受控字段若已归属静态组 → 该行跳过;受控字段全部已归属 → + * 整个条件组跳过,父字段落回「其他参数」当普通行(如 BattleTask 的「配置选择」); + * · 未归属任何组/条件/受控链的字段 → 「其他参数」。 + */ + const COND_NEST_CAP = 2; + + function groupPopContent(task, schema) { + const fields = schema?.fields || []; + 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 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); + } + 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])); + } + + // 条件组父字段 = 「根显隐源」:不归属任何组、也不在任何控制链下游。 + // 被控字段(如全局 Battle Config 的启用排轴——fields 顺序排在根源 + // 自动技能列表之前)绝不能自己开顶层组,否则链从属关系断裂; + // 其子项在受控它的位置展开(分组吸收显隐,对齐抽屉参数面板语义) + const condParents = []; + for (const f of fields) { + 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 }); + } + + 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; + + const rendered = new Set(); // 全弹层去重:一个字段只渲染一次 + + 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: groupMap.size + condParents.length + (orphanCount ? 1 : 0) }); + 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) { + if (rendered.has(key)) return; // 已被静态组/其他位置吸收 → 不重复 + rendered.add(key); + const it = document.createElement('div'); + it.className = 'it'; + const label = document.createElement('span'); + label.textContent = labelOf(key); + it.appendChild(label); + const f = fieldsByKey[key]; + 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 项)+ 受控字段缩进列表。 + * 受控字段若已全部被静态组吸收渲染 → 整组跳过(返回 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'); + 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 + 1); + body.appendChild(list); + } + grp.append(head, body); + container.appendChild(grp); + return true; + } + + function appendStaticGroup(gkey, container, depth, seen) { + 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 = schema.groupLabels?.[gkey] || gkey; + const cnt = document.createElement('span'); + cnt.className = 'cnt'; + cnt.textContent = t('itemsCount', { count: children.length }); + head.append(name, cnt); + const body = document.createElement('div'); + body.className = 'grp-body'; + for (const key of children) { + // 静态组嵌静态组;seen 沿递归路径防环(「多账户模式」组 children 含自己: + // 跳过递归但渲染字段行,字段本体仍可见且只出现一次) + if (groupMap.has(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); + container.appendChild(grp); + } + + // 静态组(嵌套子组随父组渲染,不重复出现在顶层; + // 自引用不算被嵌套——ok-end-field 的「多账户模式」组 children 就是它自己) + const nested = new Set(); + 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, new Set([gkey])); + } + // 条件组(只给根显隐源开组;受控字段已全部归属静态组 → 整组跳过, + // 父字段落回「其他参数」当普通行,如 BattleTask 的「配置选择」) + const skippedCondParents = []; + for (const { field, rules } of condParents) { + 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'; + 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; + } + + 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) { + window.clearTimeout(popHideTimer); // 关键:撤掉上一张卡 mouseleave 挂起的隐藏定时器, + // 否则快速移到相邻卡片时,新弹层显示 ~40ms 后被旧定时器藏掉(真机复现过) + if (Date.now() < popSuppressUntil) return; + const content = groupPopContent(task, schema); + if (!content) return; + 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', () => { + // 焦点仍留在宿主卡内(键盘用户 Tab 进来的)时,指针离开弹层不隐藏 + if (popFocusCard && popFocusCard.contains(document.activeElement)) return; + hideHoverPop(); + }); + document.body.appendChild(popEl); + } + popEl.replaceChildren(content); + popEl.classList.remove('is-visible'); // 先归零再测量,避免位置跳变闪烁 + const rect = card.getBoundingClientRect(); + const vh = window.innerHeight; + // 恒定左侧展开(用户要求:不回退右侧)。空间不足先收窄宽度(下限 180px), + // 连下限都放不下就贴主栏左缘——左界钳在 .lay-main 内 +8px(找不到主栏退回 + // 视口 8px),弹层不允许盖住侧栏;再不够就允许压住卡片,悬停即走无碍。 + const POP_W = 250; // 与 console.css .gpop 的 width 保持一致 + 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(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`; + popEl.classList.add('is-visible'); + } + + /** 切分段后短暂抑制弹出(弹出层规范:点 Tab 后 1.2s 内不弹 + 内容淡入重放) */ + function suppressPopups() { + popSuppressUntil = Date.now() + 1200; + hideHoverPop(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) { const key = taskKey(task); const schema = state.schemas[key]; @@ -143,6 +467,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,9 +503,42 @@ // 点击「参数」按钮时由 console.js 把这个节点搬运进抽屉显示。 const configPanel = buildConfigPanel(task, schema); card.append(header, configPanel); + // 悬停弹出:有分组层次按组展示,没层次但有字段折叠成「参数」伪组(见 groupPopContent) + 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', () => { + // 键盘可达性:焦点仍在卡内(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; + } + /** 单个卡片的执行状态:触发任务看入列/轮询,一次性任务看排队/执行 */ function cardState(card) { const executor = state.executor; @@ -203,6 +564,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 +588,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 +628,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() { @@ -377,6 +770,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); @@ -396,5 +790,5 @@ renderTasks(state.currentTasks); } - globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup }; + globalThis.TaskLauncherTaskCard = { renderTasks, updateRunningState, setSearch, toggleGroup, suppressPopups, hideHoverPop, bindHoverPop, snapshotDiffersFromFactory }; })(); diff --git a/src/localization.ts b/src/localization.ts index 046465e..57ae5ff 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -242,6 +242,17 @@ const ZH_CN: WebviewStrings = { diffTag: '与出厂不同', runningCount: '● {count} 运行中', projectStoreTag: '项目自建 store', + healthTitle: '配置健康度', + healthGroups: '快照组', + healthFields: '快照字段', + healthOwned: '已定制任务', + queueTitle: '执行队列', + depsPopSub: '参数分 {count} 组', + depsPopTaken: '已接管', + depsPopOthers: '其他参数', + condGroupTag: '显隐组', + boolOn: '开', + boolOff: '关', jsonEditBtn: 'JSON 编辑', openFileBtn: '打开文件', openDataBtn: '打开数据位置', @@ -432,6 +443,17 @@ 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', + depsPopOthers: 'Other parameters', + condGroupTag: 'Conditional', + boolOn: 'On', + boolOff: 'Off', jsonEditBtn: 'JSON edit', openFileBtn: 'Open file', openDataBtn: 'Open data location', @@ -483,6 +505,17 @@ const ZH_TW: WebviewStrings = { diffTag: '與出廠不同', runningCount: '● {count} 執行中', projectStoreTag: '專案自建 store', + healthTitle: '設定健康度', + healthGroups: '快照組', + healthFields: '快照欄位', + healthOwned: '已客製任務', + queueTitle: '執行佇列', + depsPopSub: '參數分 {count} 組', + depsPopTaken: '已接管', + depsPopOthers: '其他參數', + condGroupTag: '顯隱組', + boolOn: '開', + boolOff: '關', jsonEditBtn: 'JSON 編輯', openFileBtn: '開啟檔案', openDataBtn: '開啟資料位置', @@ -556,6 +589,17 @@ const JA: WebviewStrings = { diffTag: '初期値と差異あり', runningCount: '● {count} 実行中', projectStoreTag: 'プロジェクト store', + healthTitle: '設定ヘルス', + healthGroups: 'スナップショット組', + healthFields: 'スナップショット項目', + healthOwned: 'カスタマイズ済みタスク', + queueTitle: '実行キュー', + depsPopSub: 'パラメータ {count} グループ', + depsPopTaken: '引き継ぎ済み', + depsPopOthers: 'その他のパラメータ', + condGroupTag: '表示条件', + boolOn: 'オン', + boolOff: 'オフ', jsonEditBtn: 'JSON 編集', openFileBtn: 'ファイルを開く', openDataBtn: 'データ場所を開く', @@ -628,6 +672,17 @@ const KO: WebviewStrings = { diffTag: '출고값과 차이', runningCount: '● {count} 실행 중', projectStoreTag: '프로젝트 store', + healthTitle: '설정 상태', + healthGroups: '스냅샷 그룹', + healthFields: '스냅샷 필드', + healthOwned: '커스텀 작업', + queueTitle: '실행 대기열', + depsPopSub: '파라미터 {count}개 그룹', + depsPopTaken: '관리됨', + depsPopOthers: '기타 파라미터', + condGroupTag: '조건 표시', + boolOn: '켬', + boolOff: '끔', jsonEditBtn: 'JSON 편집', openFileBtn: '파일 열기', openDataBtn: '데이터 위치 열기', @@ -700,6 +755,17 @@ 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', + depsPopOthers: 'Otros parámetros', + condGroupTag: 'Condicional', + boolOn: 'Sí', + boolOff: 'No', jsonEditBtn: 'Editar JSON', openFileBtn: 'Abrir archivo', openDataBtn: 'Abrir ubicación de datos',