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:无
- 依赖 stores:
useLayoutStore / useStageStore / useThemeStore / useTuningDialog
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
- 未授权 Pill:
locked === 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