跳转至

20 · Shell 组件拆分树

📌 本文目标:定义 XiStudioShell.vue 的子组件树、每个子组件的 Props/Emits/Slots 契约,让前端智能体可以按合同写组件骨架


1. 组件树总览

XiStudioShell.vue                       ← 根布局(grid-template-areas)
├── MenuBar.vue                         ← ① 28px · traffic-lights / Logo / 9 菜单 / License / Theme
├── StageBar.vue                        ← ② 44px · 4 Pill + ToolBar 工具组 + ⌘K
├── SubTabs.vue                         ← ③ 32px · 子视图切换
├── ActivityBarLeft.vue                 ← 左 Activity Bar(dock-icon 列)
├── ActivityBarRight.vue                ← 右 Activity Bar
├── DrawerLeft.vue                      ← 左 Drawer(项目树/Inspector/IP库 ...)
│   └── DrawerHeader.vue                  · grid 两行 · title + ellipsis
├── DrawerRight.vue                     ← 右 Drawer
│   └── DrawerHeader.vue
├── StageFrame.vue                      ← ⑤ 中央 iframe + bridge
├── BottomPanel.vue                     ← ⑦ 220px · 5 tab 互斥
├── StatusBar.vue                       ← ⑧ 24px · 三分区
└── TuningDialog.vue                    ← 跨 Stage 浮窗(<Teleport to="body">)

2. XiStudioShell.vue(根组件)

2.1 模板骨架

<template>
  <div
    class="ide"
    :class="{ locked: layout.locked }"
    :data-theme="theme.current"
    :style="gridStyle"
  >
    <div class="topbar">
      <MenuBar />
      <StageBar />
      <SubTabs />
    </div>
    <ActivityBarLeft />
    <div class="body">
      <StageFrame />
    </div>
    <ActivityBarRight />
    <BottomPanel v-if="layout.bottom.visible" />
    <StatusBar />

    <DrawerLeft v-if="layout.drawerLeft.visible" />
    <DrawerRight v-if="layout.drawerRight.visible" />

    <Teleport to="body">
      <TuningDialog v-if="tuning.visible" />
    </Teleport>

    <Toaster />
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useLayoutStore } from '@/stores/layout'
import { useStageStore } from '@/stores/stage'
import { useThemeStore } from '@/stores/theme'
import { useTuningDialog } from '@/composables/useTuningDialog'

const layout = useLayoutStore()
const stage = useStageStore()
const theme = useThemeStore()
const tuning = useTuningDialog()

// 动态 grid-template-columns / rows(根据 Drawer dockPos + ratio 计算)
const gridStyle = computed(() => buildGridTemplate(layout))
</script>

2.2 Props / Emits

  • Props:无(根组件,从 store 读所有状态)
  • Emits:无
  • 依赖 storesuseLayoutStore / useStageStore / useThemeStore / useTuningDialog

3. MenuBar.vue(① 28px)

3.1 视觉契约

┌──────────────────────────────────────────────────────────────────────┐
│ ● ● ●  ◆ XiStudio  | 文件 编辑 视图 项目 构建 调试 工具 窗口 帮助 | [Pro] [XiMind 在线] [🎨] │
└──────────────────────────────────────────────────────────────────────┘

3.2 子结构

<div class="menubar">
  <TrafficLights />                              <!-- 红/黄/绿 12px×3 -->
  <Logo />                                       <!-- ◆ XiStudio -->
  <MenuItems :items="menus" @click="onMenuClick" />
  <Spacer />
  <LicenseTag :tier="license.tier" @click="openLicenseDialog" />
  <AiStatusTag :online="ximind.online" />
  <ThemePicker v-model="theme.current" />
</div>

3.3 Props / Emits

名称 类型 说明
Props
Emits menu:click { menuId: string, itemId: string } 9 菜单项点击
Emits license:open void License Dialog 打开请求

4. StageBar.vue(② 44px · 核心)

4.1 视觉契约

┌─────────────────────────────────────────────────────────────────────────────────┐
│ [🔗 XiLink][🔨 XiForge][🎚 XiTune][🧪 XiTest] │ 文件▾ 构建▾ 编辑▾ 视图▾ │ ⌘K │
└─────────────────────────────────────────────────────────────────────────────────┘

4.2 子结构

<div class="stagebar">
  <!-- 左半区:4 Pill -->
  <div class="stage-pills">
    <StagePill
      v-for="s in stages"
      :key="s.key"
      :stage="s"
      :active="stage.current === s.key"
      :locked="!license.canEnter(s.key)"
      @click="onPillClick(s.key)"
    />
  </div>

  <div class="stagebar-divider" />

  <!-- 右半区:ToolBar -->
  <ToolBarGroup
    v-for="group in toolbar.groups"
    :key="group.id"
    :group="group"
    @button:click="onToolbarClick"
  />

  <Spacer />
  <CommandPaletteTrigger @click="openCommandPalette" />  <!-- ⌘K -->
</div>

4.3 Props / Emits

名称 类型 说明
Emits stage:switch { key: 'xilink'\|'xitune'\|'xiforge'\|'xitest' } Pill 点击
Emits toolbar:click { stage: string, id: string } 工具按钮点击(转发到 Stage iframe)
Emits command-palette:open void ⌘K 触发

4.4 关键逻辑

  • toolbar.groups 来源:由 Stage 通过 postMessage toolbar-inject 注入,存在 useStageBarStore().buttons
  • 响应式收缩:用 VueUse 的 useWindowSize() watch 宽度,< 1280 隐藏 label
  • 未授权 Pilllocked === true 时点击触发 useToast().show('License 未授权...'),不切 Stage

5. SubTabs.vue(③ 32px)

<div class="subtabs">
  <SubTabBtn
    v-for="t in subtabs"
    :key="t.key"
    :tab="t"
    :active="stage.subtab === t.key"
    @click="stage.setSubtab(t.key)"
  />
</div>
Emits 说明
subtab:change { subtab: string } Stage 内部子视图切换(也通过 useStageBridge 发 subtab 消息给 iframe)

6. ActivityBarLeft.vue / ActivityBarRight.vue

6.1 视觉契约

垂直一列 38×38 dock-icon(codex.css §6 强制要求 .dock-icon 类名)。

6.2 子结构

<div class="actL">
  <DockIcon
    v-for="icon in icons"
    :key="icon.key"
    :icon="icon"
    :active="layout.drawerLeft.activeKey === icon.key"
    @click="onDockIconClick(icon.key)"
  />
</div>

6.3 关键逻辑(v3.4 单击切换 bug 修复)

function onDockIconClick(key: string) {
  const current = layout.drawerLeft.activeKey
  if (current === key) {
    // 单击同一个 → toggle off
    layout.setActiveIcon('left', null)
    layout.setDrawerVisible('left', false)
  } else {
    // 单击新的 → 切换
    layout.setActiveIcon('left', key)
    layout.setDrawerVisible('left', true)
  }
}

7. DrawerLeft.vue / DrawerRight.vue

7.1 视觉契约

┌─ Drawer Header ─────────────────────────┐
│ Title (主)                       [📌][⊕]│ ← grid-template-columns: 1fr auto
│ Subtitle (副 · ellipsis)                │ ← grid-template-rows: auto auto
├─ Drawer Tab Bar ────────────────────────┤
│ 项目树 / Inspector / 库 / ...           │
├─ Drawer Body ───────────────────────────┤
│ <slot />(Tab Pane 内容)                │
└─────────────────────────────────────────┘

7.2 子结构

<div class="drawer" :class="{ show: layout.drawerLeft.visible }" :data-dock-pos="layout.drawerLeft.dockPos">
  <DrawerHeader
    :title="currentPanel.title"
    :subtitle="currentPanel.subtitle"
    @pin="layout.toggleLock"
    @dock-pos:change="onDockPosChange"
  />
  <DrawerTabBar v-if="currentPanel.tabs" :tabs="currentPanel.tabs" />
  <div class="drawer-body">
    <component :is="currentTabPane" />
  </div>
  <Resizer @mousedown="onResizeStart" />
</div>

7.3 关键逻辑

  • dockPos 切换@dock-pos:change 调用 useDrawerDock().setDockPos('left', pos),watch dockPos 自动应用 grid 槽位
  • Inspector 空槽:当 currentPanel.key === 'inspector',渲染 <div ref="inspectorSlot" v-html="layout.inspectorHtml" />,由 Stage 通过 inspector-inject 注入

7.4 Props / Emits

名称 类型 说明
Props side 'left' \| 'right' (DrawerLeft 默认 left,DrawerRight 默认 right)
Emits resize:start MouseEvent 拖拽开始
Emits dock-pos:change 'left' \| 'right' \| 'top' \| 'bottom' 4 位置切换

8. StageFrame.vue(⑤ 核心 iframe 容器)

8.1 模板

<template>
  <iframe
    ref="frameRef"
    :src="STAGE_SRC[stage.current]"
    class="stage-frame"
    @load="onFrameLoad"
  />
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useStageStore } from '@/stores/stage'
import { useStageBarStore } from '@/stores/stage-bar'
import { useLayoutStore } from '@/stores/layout'
import { useStageBridge } from '@/composables/useStageBridge'

const STAGE_SRC = {
  xilink:  '/stages/stage-xilink.html',
  xitune:  '/stages/stage-xitune.html',
  xiforge: '/stages/stage-xiforge.html',
  xitest:  '/stages/stage-xitest.html',
}

const stage = useStageStore()
const stageBar = useStageBarStore()
const layout = useLayoutStore()
const frameRef = ref<HTMLIFrameElement | null>(null)
const bridge = useStageBridge(frameRef)

// 注册 8 类 Stage→Shell handler
bridge.on('stage-ready', (msg) => stage.markReady(msg.stage, msg.version))
bridge.on('toolbar-inject', (msg) => stageBar.setButtons(msg.buttons))
bridge.on('stage-status', (msg) => layout.setStageStatusHtml(msg.html))
bridge.on('stage-hint', (msg) => layout.setStageHintHtml(msg.html))
bridge.on('select-node', (msg) => layout.setDrawerTitle('right', msg.label))
bridge.on('tuning-dialog', (msg) => useTuningDialog().open(msg.module, msg.label))
bridge.on('show-toast', (msg) => useToast().show(msg.message))
bridge.on('inspector-inject', (msg) => layout.setInspectorHtml(msg.html, msg.module))
bridge.on('module-properties-inject', (msg) => layout.setModulePropertiesHtml(msg.html, msg.module))

// Stage 切换时清空所有"上一个 Stage 注入"的槽位
watch(() => stage.current, () => {
  stageBar.clearButtons()
  layout.clearStageStatusHtml()
  layout.clearStageHintHtml()
  layout.clearInspectorHtml()
  layout.clearModulePropertiesHtml()
})

function onFrameLoad() { /* iframe loaded, wait for stage-ready msg */ }
</script>

8.2 关键约束

  • iframe sandbox 策略:暂不加 sandbox attr(Stage 是可信内部页面);如未来加入用户脚本沙箱,需重审
  • 来源校验:在 useStageBridge 内部已校验 e.source === frameRef.value?.contentWindow

9. BottomPanel.vue(⑦ 220px · 5 tab 互斥)

9.1 子结构

<div class="bottom" :data-ratio="layout.bottom.ratio">
  <div class="bottom-tabbar">
    <BottomTabBtn
      v-for="t in tabs"
      :key="t.key"
      :tab="t"
      :active="layout.bottom.activeKey === t.key"
      @click="layout.setBottomTab(t.key)"
    />
    <Spacer />
    <div id="stageHintSlot" v-html="layout.stageHintHtml" />  <!-- v3.2 hint 槽 -->
  </div>

  <!-- 5 个 Pane 同时存在但只一个 active -->
  <BottomPaneBuild :class="{ active: layout.bottom.activeKey === 'build' }" />
  <BottomPaneProblems :class="{ active: layout.bottom.activeKey === 'problems' }" />
  <BottomPaneTerminal :class="{ active: layout.bottom.activeKey === 'terminal' }" />
  <BottomPaneLog :class="{ active: layout.bottom.activeKey === 'log' }" />
  <BottomPaneProperties :class="{ active: layout.bottom.activeKey === 'properties' }">
    <div id="modulePropertiesSlot" v-html="layout.modulePropertiesHtml" />
  </BottomPaneProperties>

  <Resizer @mousedown="onResizeStart" />
</div>

9.2 关键约束

  • Tab 互斥(v3.2):codex.css 中 .bottom-pane 默认 display:none,仅 .active 显示
  • #stageHintSlot(v3.2):Stage 通过 stage-hint 消息注入快捷键提示 HTML
  • #modulePropertiesSlot(v3.4):Stage 通过 module-properties-inject 消息注入

10. StatusBar.vue(⑧ 24px · 三分区)

10.1 视觉契约

┌──────────────────────────────────────────────────────────────────────┐
│ [License Pro 30天] [🟢 XiCore] [44.1k/512] [📡 XiCal·XiProbe] | 编译... | [🤖 在线] [Stage:XiLink] [User] │
└──────────────────────────────────────────────────────────────────────┘

10.2 子结构(三分区)

<div class="status">
  <!-- 左 -->
  <div class="status-left">
    <LicenseStatus :tier="license.tier" :daysLeft="license.daysLeft" />
    <EngineLED :state="engine.state" />
    <SampleRate :rate="engine.sampleRate" :buffer="engine.bufferSize" />
    <DeviceList :devices="devices.connected" @click="openDevicePopover" />
  </div>

  <!-- 中(Stage 注入 + 编译进度) -->
  <div class="status-center">
    <CompileProgress v-if="build.progress" :percent="build.progress" />
    <div id="stageStatusSlot" v-html="layout.stageStatusHtml" />
  </div>

  <!-- 右 -->
  <div class="status-right">
    <AiStatus :online="ximind.online" />
    <CurrentStageName :stage="stage.current" />
    <UserAvatar :user="user.current" />
  </div>
</div>

10.3 关键约束

  • #stageStatusSlot:Stage 通过 stage-status 消息注入(位于 status-center 区)

11. TuningDialog.vue(跨 Stage 浮窗)

11.1 模板

<template>
  <div
    v-show="tuning.visible && !tuning.minimized"
    class="tuning-dialog"
    :style="{ left: `${tuning.position.x}px`, top: `${tuning.position.y}px` }"
    @mousedown="onDragStart"
  >
    <div class="tuning-header">
      <TrafficLights @close="tuning.close" @minimize="tuning.minimize" />
      <span class="tuning-title">{{ tuning.label }}</span>
    </div>
    <div class="tuning-body">
      <PeqEditor :module="tuning.module" />
      <FrequencyResponseChart :data="peqResponse" />
      <AbToggle v-model="abState" />
    </div>
  </div>
</template>

11.2 关键约束

  • <Teleport to="body"> 渲染(脱离 grid,避免 z-index 问题)
  • Stage 切换时继续存在(不销毁实例)
  • 最小化进 BottomPanel B6 Tuning 托盘

12. 子组件清单与 Props 速查表

组件 主要 Props 主要 Emits
XiStudioShell
MenuBar menu:click / license:open
StageBar stage:switch / toolbar:click / command-palette:open
StagePill stage active locked click
ToolBarGroup group button:click
SubTabs subtab:change
ActivityBarLeft/Right dock-icon:click
DockIcon icon active click
DrawerLeft/Right side resize:start / dock-pos:change
DrawerHeader title subtitle pin / dock-pos:change
Resizer direction mousedown
StageFrame
BottomPanel tab:change / resize:start
StatusBar
TuningDialog close / minimize

13. 命名规范(避免与 codex.css 冲突)

类型 规范
组件名 PascalCase MenuBar.vue
文件名 PascalCase MenuBar.vue
props/data camelCase dockPos
events kebab-case + 冒号分隔 dock-pos:change
CSS 类 完全沿用 codex.css.ide .topbar .dock-icon 等) 不自创
store id camelCase useLayoutStore'layout'

v1.0 · 2026-05-17 · D4 前端改造手册 · Shell 组件拆分树 · 配套 v1.2.2-impl §12