跳转至

XiStudio Layout Demo · 顶栏架构重构 v4 方案

状态:📋 待评审
创建:2026-05-19
范围:Shell index.html + 4 个 stage(XiLink / XiTune / XiForge / XiTest)+(可选)xistudio-codex.css 微调
目标:把分散在各 stage 内的 doc-tabs / mode-bar / 通用工具按钮,按职责重新归位到 Shell 顶栏的"三段式",让顶栏成为 Stage 切换 + 主界面切换 + 全局工具的统一入口。


1. 用户决策回顾

决策点 用户选择
A. doc-tabs 去留 保留 XiLink / XiTune(子图切换需要);XiForge / XiTest 删除
B. mode-bar 位置 移到 Shell 顶栏右半部分(不再嵌在 stage 内)
C. 改造范围 Y · 一次到位完整重构 所有 4 个 stage
D. 音频引擎 Shell 全局右段共用,3 按钮(▶ Start / ■ Stop / 🔄 Restart)+ 状态灯

2. 现状诊断(基于代码精确读取)

2.1 Shell 顶栏现状(index.html L1040-1077)

[traffic-lights] [logo] [☰menu] [.main-tabs#stagePills (4 stage Pill)]
  [.toolbar-group#stageToolbar ← Stage 注入]
  [.float-mgr 浮窗] [.lock-toggle 锁定] [.lock-toggle 主题] [.lic-tag]
- L1066<div class="toolbar-group" id="stageToolbar"> 是当前唯一的 stage 注入位(被 4 stage 都用作"通用工具按钮区") - L1072:主题/锁定/浮窗都是 Shell 全局按钮(保持不动)

2.2 codex.css 已预留双行顶栏机制(L243-310)✨ 关键发现

.ide.with-subtabs-bar { --topbar-h: 70px; }   /* 顶栏 44 → 70px */
.ide.with-subtabs-bar .topbar { height: 44px; }
.sub-tabs-bar {
  grid-area: topbar;       /* 同一个 grid 区域 */
  align-self: end;          /* 贴底部 */
  height: 26px;
  /* 玫瑰金小三角::before 指向 active 主 tab */
}
结论:CSS 层已经支持双行顶栏(44 + 26 = 70px),无需改 grid。我们只需在 .ide 上加 .with-subtabs-bar class,并在 .topbar 之后插入 <div class="sub-tabs-bar"> 即可。

2.3 Shell 现有清理逻辑(L2141-2161)

switchStage(stageKey) 已经在切 iframe.src 之前清空: - #stageToolbar.innerHTML = '' - #stageStatusSlot.innerHTML = '' - #stageHintSlot.innerHTML = '' - resetStageDock()

→ 我们扩展时只需在这里追加一行清空 #modeSwitcher

2.4 各 stage toolbar-inject 按钮现状

Stage 按钮(id · label) 重新归类建议
XiLink xl-update 更新链路 / xl-verify 拓扑校验 / xl-align 节点对齐 / xl-compile 编译 / xl-flash 烧录 全归"通用工具"(链路操作)
XiForge (子代理截断未拿全,重构时按代码现读) 模块开发操作归"通用工具"
XiTune xt-autoeq 自动 EQ / xt-ear 黄金耳 / xt-ab A/B / xt-export 导出 / xt-reset 重置 全归"通用工具"(调音操作)
XiTest xt-run 运行 / xt-stop 停止 / xt-rerun 重跑 / xt-capture 捕获 / xt-export 导出 全归"通用工具"(测试操作)

音频引擎 ▶/■/🔄 在 4 个 stage 都通用,所以不放任何 stage 的 toolbar,而是 Shell 顶栏直接渲染


3. 新顶栏架构设计

3.1 顶栏 DOM 三段式(重构后)

┌────────────────────── 行 1 (44px) ──────────────────────────────────────┐
│ [🚦] XiStudio [☰]                                                        │
│ ├─ 左段 ─┤ [🔗XL][🔨XF][🎚XT][🧪XS]  ← stage 切换器(保持不变)          │
│ ├─ 中段 ─┤ ★ 留空 / 由 stage 通过 mode-switcher-inject 注入             │
│ ├─ 右段 ─┤ [🛠 通用工具组 #stageToolbar] | [▶ ■ 🔄 ●] [🪟] [🔒] [🎨] [Demo] │
└─────────────────────────────────────────────────────────────────────────┘
┌────────────────────── 行 2 (26px · 仅 XL/XT) ─────────────────────────────┐
│ ▾ DOC TABS · main_chain.xilink ●  subgraph_eq.xilink  multi_band.xilink  + │
└─────────────────────────────────────────────────────────────────────────┘

关键说明: 1. 行 2(sub-tabs-bar)只对 XiLink / XiTune 显示——通过 .ide.with-subtabs-bar class 切换。XiForge/XiTest active 时移除该 class,顶栏回到单行 44px。 2. 中段(mode-switcher-slot)只对需要的 stage 显示——目前只有 XiTest 的 5 个 mode chip(SMOKE/INTEG/ELEC/ACOU/LIVE)。XiLink/XiTune/XiForge 注入空数组或不发协议,中段为空。 3. 右段顺序固定#stageToolbar(stage 通用工具,可变)→ 音频引擎组(Shell 固定)→ 浮窗/锁/主题/lic(Shell 固定)。

3.2 新增 DOM 元素(index.html)

在 L1063 </div> 之后(.main-tabs 闭合后)插入"中段 mode-switcher-slot":

<!-- 中段:mode-switcher(由当前 Stage 通过 mode-switcher-inject 注入;空时不占位) -->
<div class="mode-switcher" id="modeSwitcher"><!-- empty by default --></div>

在 L1066 #stageToolbar 之后插入"音频引擎全局组":

<!-- Shell 全局:音频引擎控制(所有 Stage 共用 · 不归任何 Stage) -->
<div class="audio-engine" id="audioEngine" data-state="stopped">
  <button class="ae-btn ae-start" id="aeStart" title="启动音频引擎"></button>
  <button class="ae-btn ae-stop"  id="aeStop"  title="停止音频引擎"></button>
  <button class="ae-btn ae-restart" id="aeRestart" title="重启音频引擎">🔄</button>
  <span class="ae-state" id="aeState" title="引擎状态"></span>
</div>

</div> 顶栏闭合(L1077)之后插入"sub-tabs-bar 行 2":

</div><!-- /.topbar 行 1 闭合 -->

<!-- 行 2:doc-tabs(仅 XL/XT 显示 · 由 .ide.with-subtabs-bar 控制可见性) -->
<div class="sub-tabs-bar" id="docTabsBar">
  <!-- 由当前 Stage 通过 doc-tabs-inject 注入;XF/XS 时清空,且 .ide 移除 with-subtabs-bar -->
</div>

3.3 CSS 微调(追加到 index.html <style> 内或 codex.css 末尾)

/* 顶栏中段:mode-switcher(chip 风格,对齐 XiTest 现有 mode-bar 视觉) */
.topbar .mode-switcher {
  display: inline-flex; align-items: center; gap: 4px;
  margin-left: 6px; flex-shrink: 1;
}
.topbar .mode-switcher:empty { display: none; }
.topbar .mode-chip {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 10px; border-radius: 999px;
  background: transparent; border: 1px solid var(--line);
  color: var(--fg-1); font-size: 11px; cursor: pointer;
  white-space: nowrap;
}
.topbar .mode-chip:hover { border-color: var(--accent); color: var(--accent); }
.topbar .mode-chip.active {
  background: var(--accent); color: var(--accent-fg); border-color: var(--accent);
}
.topbar .mode-chip .chip-tag {
  font-size: 8.5px; padding: 1px 4px; border-radius: 3px;
  background: var(--bg-2); color: var(--fg-2); font-weight: 700;
  font-family: ui-monospace, monospace;
}
.topbar .mode-chip.active .chip-tag {
  background: rgba(255,255,255,.25); color: #fff;
}

/* 顶栏右段:音频引擎全局组 */
.topbar .audio-engine {
  display: inline-flex; align-items: center; gap: 2px;
  margin-left: 8px; flex-shrink: 0;
  padding: 2px 6px; border-radius: 6px;
  background: var(--bg-2); border: 1px solid var(--line);
}
.topbar .audio-engine .ae-btn {
  background: transparent; border: none; color: var(--fg-1);
  width: 22px; height: 22px; border-radius: 4px;
  font-size: 11px; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.topbar .audio-engine .ae-btn:hover { background: var(--bg-3); color: var(--accent); }
.topbar .audio-engine .ae-state {
  font-size: 10px; margin-left: 4px;
  width: 10px; text-align: center;
}
.topbar .audio-engine[data-state="running"] .ae-state { color: var(--ok);  text-shadow: 0 0 4px var(--ok); }
.topbar .audio-engine[data-state="stopped"] .ae-state { color: var(--fg-2); }
.topbar .audio-engine[data-state="error"]   .ae-state { color: var(--err); text-shadow: 0 0 4px var(--err); }
.topbar .audio-engine[data-state="running"] .ae-start { color: var(--ok); }
.topbar .audio-engine[data-state="stopped"] .ae-stop  { opacity: .4; cursor: default; }
.topbar .audio-engine[data-state="running"] .ae-stop  { color: var(--err); opacity: 1; cursor: pointer; }

/* sub-tabs-bar 内的 doc-tabs(搬过来,复用 stage 现有 .doc-tab 样式) */
.sub-tabs-bar .doc-tab {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 0 10px; height: 22px;
  font-size: 11px; color: var(--fg-2);
  border: 1px solid transparent; border-radius: 4px 4px 0 0;
  background: transparent; cursor: pointer;
  white-space: nowrap;
}
.sub-tabs-bar .doc-tab:hover { background: var(--bg-2); color: var(--fg-1); }
.sub-tabs-bar .doc-tab.active {
  background: var(--canvas-bg); color: var(--accent);
  border-color: var(--line); font-weight: 600;
}
.sub-tabs-bar .doc-tab.dirty::after {
  content: '●'; color: var(--accent); font-size: 8px; margin-left: 2px;
}
.sub-tabs-bar .doc-tab .close {
  font-size: 10px; color: var(--fg-2); margin-left: 2px;
}
.sub-tabs-bar .new-tab {
  width: 22px; height: 22px;
  background: transparent; border: none; color: var(--fg-2);
  font-size: 14px; cursor: pointer; border-radius: 4px;
}
.sub-tabs-bar .new-tab:hover { background: var(--bg-2); color: var(--accent); }

4. 新协议规范(4 个新增 + 1 个新增反向)

4.1 mode-switcher-inject (Stage → Shell)

用途:Stage 主动声明它需要在顶栏中段显示一组"主界面切换 chip"。
字段

window.parent.postMessage({
  type: 'mode-switcher-inject',
  stage: STAGE,
  chips: [                     // 必填,数组;空数组 = 清空中段
    { key: 'smoke',       icon: '🧪', label: '冒烟',   tag: 'SMOKE' },
    { key: 'integration', icon: '🔗', label: '集成',   tag: 'INTEG' },
    { key: 'electrical',  icon: '⚡', label: '电性能', tag: 'ELEC'  },
    { key: 'profile',     icon: '🎯', label: 'Profile', tag: 'ACOU' },
    { key: 'realtime',    icon: '📡', label: 'Live',   tag: 'LIVE'  }
  ],
  activeKey: 'realtime'         // 可选,默认 chips[0].key
}, '*');

Shell 渲染逻辑:清空 #modeSwitcher → forEach 创建 .mode-chip[data-key],含 chip-tag → 点击转发 mode-switch-click
点击反向消息(Shell → Stage):

{ type: 'mode-switch-click', stage, key: 'electrical' }

4.2 doc-tabs-inject (Stage → Shell)

用途:仅 XiLink / XiTune 调用,把 stage 内的 doc-tabs 搬到 Shell 行 2。
字段

window.parent.postMessage({
  type: 'doc-tabs-inject',
  stage: STAGE,
  tabs: [                       // 必填,数组;空数组 = 隐藏行 2
    { key: 'main',         icon: '🔗', label: 'main_chain.xilink',     dirty: true },
    { key: 'subgraph_eq',  icon: '📐', label: 'subgraph_eq.xilink',    dirty: false },
    { key: 'multi_band',   icon: '🎚', label: 'multi_band_dyn.xilink', dirty: false }
  ],
  activeKey: 'main',
  showNewTabButton: true        // 可选,是否显示尾部 "+" 按钮
}, '*');

Shell 渲染逻辑: - 若 tabs.length > 0 → 给 .idewith-subtabs-bar class,渲染 #docTabsBar 内容; - 若 tabs.length === 0 → 移除 with-subtabs-bar,清空 #docTabsBar

反向消息

{ type: 'doc-tab-click',     stage, key: 'subgraph_eq' }   // 切换
{ type: 'doc-tab-close',     stage, key: 'subgraph_eq' }   // 关闭按钮
{ type: 'doc-tab-new',       stage }                         // + 按钮

4.3 engine-state-changed (Shell → 所有 Stage 广播)

用途:Shell 音频引擎状态变化时,向当前激活 stage(也可考虑广播到所有 stage)发送状态。
字段

frame.contentWindow.postMessage({
  type: 'engine-state-changed',
  state: 'running',     // 'running' | 'stopped' | 'restarting' | 'error'
  sampleRate: 48000,
  bufferSize: 256,
  latencyMs: 1.3
}, '*');

Stage 接收后:可在自身 UI(如 XiTune io-bar 的 LIVE 灯、XiTest mode-bar 的 LIVE 标记)联动显示。
音频引擎按钮反向:Shell 内部按钮直接改 data-state 即可,不需要 stage 触发;但若 stage 想主动启停,可发反向消息 engine-control

{ type: 'engine-control', action: 'start' | 'stop' | 'restart' }


5. 各 stage 改造点对照表

Stage 改造动作 涉及代码 doc-tabs mode-switcher 通用工具按钮
XiLink 删除 stage 内 .doc-tabs HTML(L284-302)+ 改用 doc-tabs-inject 协议;toolbar-inject 不变(5 个链路操作按钮归通用工具) stage-xilink.html L284-302 删除 + 启动时 postMessage 通过协议注入 不需要 5 个保留
XiTune 同上:删除 stage 内 .doc-tabs + 改 doc-tabs-inject 协议;同时实现"doc-tab-click 反向消息 → renderMiniBar 联动"(替换原 stage 内 .doc-tab click 监听) stage-xitune.html 删 doc-tabs DOM + 改 message handler 通过协议注入 不需要 5 个保留
XiForge 删除 stage 内 doc-tabs(一次只开一个 module 项目) stage-xiforge.html 不需要 现有按钮归通用工具
XiTest 删除 stage 内 doc-tabs + 删除 stage 内 mode-bar(搬到 Shell 顶栏中段) + 改 mode-switcher-inject 协议 + mode-switch-click 反向消息处理 → renderMode stage-xitest.html 删 L800-880 mode-bar HTML + 改 message handler 通过协议注入 5 chip 5 个保留

6. Shell 改造点(index.html)

6.1 DOM 修改

行号 动作 内容
L1063 后 新增 <div class="mode-switcher" id="modeSwitcher"></div>
L1066 保持 <div class="toolbar-group" id="stageToolbar">(不动)
L1066 后 新增 <div class="audio-engine" id="audioEngine" data-state="stopped">…</div>
L1077 后 新增 <div class="sub-tabs-bar" id="docTabsBar"></div>

6.2 JS 修改

函数 / 位置 动作
switchStage() L2141-2161 追加 清空逻辑:document.getElementById('modeSwitcher').innerHTML = ''; document.getElementById('docTabsBar').innerHTML = ''; document.querySelector('.ide').classList.remove('with-subtabs-bar');
message handler L2210-2245 新增 case 'mode-switcher-inject'injectModeSwitcher(msg) 'doc-tabs-inject'injectDocTabs(msg) 'engine-control'handleEngineControl(msg)
新增函数 injectModeSwitcher(msg) 渲染 mode-chip + 点击转发 mode-switch-click injectDocTabs(msg) 渲染 sub-tabs-bar + 切换 with-subtabs-bar class + 点击转发 doc-tab-click/close/new setEngineState(state)#audioEngine[data-state] + 广播 engine-state-changed handleEngineControl(msg) 接收 stage 主动控制(可选)
启动时绑定 #aeStart #aeStop #aeRestart 的 click → 调 setEngineState(demo 直接切状态 + 广播)

7. 风险与回滚点

7.1 主要风险

  1. .ide.with-subtabs-bar 切换时机:必须在 switchStage() 切 iframe.src 之前就移除 class,避免视觉抖动;新 stage 加载后通过 doc-tabs-inject 重新加 class。
  2. mode-switcher 闪烁:stage iframe 加载到 stage-ready 之间会有短暂空窗,中段会闪一下"空"。可接受(XiTest 切到 XiLink 等场景)。
  3. 音频引擎状态广播:当 stage iframe 还未 ready 时,Shell 不应发送 engine-state-changed。建议在 stage 发出 stage-ready 后再广播一次当前 state("晚加入也能拿到状态")。
  4. doc-tabs 协议双向状态同步:stage 内部维护"当前 active doc",Shell 渲染成"active class",必须保证两边一致。建议 stage 在收到 doc-tab-click 后,再发一次 doc-tabs-inject 更新 activeKey(避免双源 state)。
  5. XiForge / XiTest 中 stage 内被删除的 doc-tabs DOM 残留 CSS 必须一并删除,否则未使用样式留在文件里。

7.2 回滚点

  • 每个改动文件都保留 v3.x 的注释签名(如 stage-xitest 当前 v3.0.0 → 改后 v4.0.0),出问题可 git diff 回滚。
  • 新协议是追加式mode-switcher-inject / doc-tabs-inject / engine-state-changed 全是新 type),不破坏 v1.2.2 的 9 种现有协议。

8. 改造提交顺序(一次到位但内部步骤)

按依赖关系: 1. Shell index.html(基础设施先行):DOM 三段式 + CSS + 5 个新函数(injectModeSwitcher / injectDocTabs / setEngineState / handleEngineControl + switchStage 扩展清理) 2. stage-xitest.html(验证 mode-switcher 协议):删 doc-tabs + 删 mode-bar HTML/CSS + 启动时发 mode-switcher-inject + 处理 mode-switch-click 反向消息 3. stage-xiforge.html(验证 XF/XS 删 doc-tabs 路径):删 doc-tabs HTML/CSS(不发 doc-tabs-inject 协议) 4. stage-xilink.html(验证 doc-tabs 协议):删 stage 内 doc-tabs HTML/CSS + 启动时发 doc-tabs-inject + 处理 doc-tab-click / doc-tab-close / doc-tab-new 反向 5. stage-xitune.html(同 XiLink + 联动 mini-bar):处理 doc-tab-clickrenderMiniBar(key)


9. 验收 checklist(用户可逐项核对)

启动 index.html,依次: - [ ] 默认进入 XiLink,顶栏行 2 显示 3 个 doc-tabs(main/subgraph_eq/multi_band),玫瑰金小三角指向 XiLink Pill - [ ] 切到 XiForge:顶栏回到单行 44px(无行 2),中段 mode-switcher 为空,右段 #stageToolbar 显示 XiForge 自己的按钮组 - [ ] 切到 XiTune:顶栏回到双行,行 2 显示 XiTune 的 3 个子图 doc-tabs;切 doc-tab 时主区 mini-bar 联动 - [ ] 切到 XiTest:顶栏单行 44px(无行 2),中段显示 5 个 mode-chip(SMOKE/INTEG/ELEC/ACOU/LIVE),默认 LIVE active;点击不同 chip 切换主区 - [ ] 顶栏右段稳定显示音频引擎组 ▶/■/🔄/●:点 ▶ 状态变 running(绿灯),■ 变 stopped(灰灯),🔄 短暂 restarting 后回 running - [ ] 音频引擎状态变化时,XiTune 的 io-bar LIVE 灯 / XiTest 的 mode-bar LIVE tag 跟随变化(联动通过 engine-state-changed 协议) - [ ] Tuning Dialog 多实例不受影响(双击 stage 内某节点仍能弹浮窗) - [ ] 控制台无报错;4 stage 切换无视觉抖动


10. 待用户确认的设计细节(动手前最后确认)

  1. 音频引擎状态广播范围:仅当前激活 stage?还是 4 个 stage 都广播?(推荐仅激活 stage,省消息)
  2. doc-tab-close / doc-tab-new 是否需要本轮实现?还是先只做 click 切换,close/new 留 v4.1?(推荐 close/new 本轮也实现,但仅前端 UI 联动,不做后端持久化)
  3. 音频引擎"重启"🔄的视觉表达:纯按钮即时执行?还是带 restarting 中间态(按钮转圈 1 秒后自动 running)?(推荐带中间态,演示更真实)
  4. 顶栏右段在屏幕较窄时的折叠策略:是否需要把音频引擎组 + 浮窗/锁/主题塞进溢出菜单?(本轮先不做溢出处理,后续 v4.1 再做)
  5. mode-switcher 视觉风格:用我设计的 chip 风格(borderless + active 实心 accent)?还是借用现有 .main-tab 的 Pill 风格保持一致性?(推荐独立 chip 风格,与 stage 切换器视觉区分)

评审通过后,我将一气改 5 个文件提交。预计代码净修改 ~900 行。