10 · 架构映射 · demo → Vue 3
📌 本文目标:把
layout-demo/index.html中的每一段实现,按"DOM 结构 → Vue 组件 / Pinia store / composable"的形式严格映射,让前端智能体可以逐段对照改造,不漏不重。
1. 顶层目录结构(Vue 3 项目)
src/
├── main.ts ← 入口(挂载 XiStudioShell + Pinia + Router)
├── App.vue ← 仅作 RouterView 容器
├── router/
│ └── index.ts ← 路由表 + license 守卫
├── stores/
│ ├── layout.ts ← useLayoutStore(窗口管理 + 持久化)
│ ├── stage.ts ← useStageStore(当前 Stage + iframe 状态)
│ ├── license.ts ← useLicenseStore(容器/Stage/Add-on 三元)
│ └── theme.ts ← useThemeStore(6 主题)
├── composables/
│ ├── useStageBridge.ts ← postMessage 9 类协议封装
│ ├── useDrawerDock.ts ← Drawer 4 位置 + 比例切换
│ ├── useWindowResize.ts ← resize/split 拖拽逻辑
│ ├── useTuningDialog.ts ← 跨 Stage 浮窗管理
│ └── usePersistedLayout.ts ← localStorage 兼容层
├── components/
│ ├── shell/
│ │ ├── XiStudioShell.vue ← 根布局组件(grid-template-areas)
│ │ ├── MenuBar.vue ← ① 顶栏第一行
│ │ ├── StageBar.vue ← ② 顶栏第二行(4 Pill + ToolBar)
│ │ ├── SubTabs.vue ← ③ 顶栏第三行
│ │ ├── ActivityBarLeft.vue ← 左 Activity Bar(dock-icon 列)
│ │ ├── ActivityBarRight.vue ← 右 Activity Bar
│ │ ├── DrawerLeft.vue ← 左 Drawer(项目树/Inspector)
│ │ ├── DrawerRight.vue ← 右 Drawer
│ │ ├── BottomPanel.vue ← ⑦ 底栏(5 tab 互斥)
│ │ └── StatusBar.vue ← ⑧ 状态栏
│ ├── stage/
│ │ └── StageFrame.vue ← iframe 容器 + bridge 接入
│ └── dialogs/
│ └── TuningDialog.vue ← 跨 Stage PEQ 浮窗
├── types/
│ └── stage-bridge.ts ← postMessage 协议 TS 类型
├── styles/
│ └── xistudio-codex.css ← 视觉法典(1:1 复制 · 严禁修改)
└── public/
└── stages/ ← Stage 静态资源(沿用 demo HTML)
├── stage-xilink.html
├── stage-xitune.html
├── stage-xiforge.html
└── stage-xitest.html
2. demo HTML 结构 → Vue 组件映射
2.1 Shell 整体结构(grid-template-areas)
demo(index.html):
<div class="ide locked" data-theme="default">
<div class="topbar">
<div class="menubar">...</div> <!-- ① -->
<div class="stagebar">...</div> <!-- ② -->
<div class="subtabs">...</div> <!-- ③ -->
</div>
<div class="actL">...</div> <!-- ④ 左 Activity Bar -->
<div class="body">
<iframe id="stageFrame">...</iframe> <!-- ⑤ Stage 区 -->
</div>
<div class="actR">...</div> <!-- ⑥ 右 Activity Bar -->
<div class="bottom">...</div> <!-- ⑦ Bottom -->
<div class="status">...</div> <!-- ⑧ Status -->
<div id="drawerLeft" class="drawer">...</div>
<div id="drawerRight" class="drawer">...</div>
<div id="tuningDialog" class="tuning-dialog">...</div>
</div>
Vue(XiStudioShell.vue):
<template>
<div class="ide" :class="{ locked: layout.locked }" :data-theme="theme.current">
<div class="topbar">
<MenuBar />
<StageBar />
<SubTabs />
</div>
<ActivityBarLeft />
<div class="body">
<StageFrame :stage="stage.current" />
</div>
<ActivityBarRight />
<BottomPanel v-if="layout.bottom.visible" />
<StatusBar />
<DrawerLeft v-if="layout.drawerLeft.visible" />
<DrawerRight v-if="layout.drawerRight.visible" />
<TuningDialog v-if="tuning.visible" />
</div>
</template>
2.2 vanilla 状态变量 → Pinia store 映射
| demo 变量(window 全局或 IIFE 内) | Pinia store 字段 | 持久化 |
|---|---|---|
currentStage |
useStageStore().current |
✅ |
layout.locked(class 切换) |
useLayoutStore().locked |
✅ |
drawerLeft.dataset.activeKey |
useLayoutStore().drawerLeft.activeKey |
✅ |
drawerLeft.dataset.dockPos |
useLayoutStore().drawerLeft.dockPos |
✅ |
drawerLeft.dataset.ratio |
useLayoutStore().drawerLeft.ratio |
✅ |
drawerRight.*(同上) |
useLayoutStore().drawerRight.* |
✅ |
bottom.dataset.activeKey |
useLayoutStore().bottom.activeKey |
✅ |
bottom.dataset.ratio |
useLayoutStore().bottom.ratio |
✅ |
currentTheme(data-theme attr) |
useThemeStore().current |
✅ |
tuningDialogState |
useTuningDialogStore().{ visible, module, label, position } |
❌(会话级) |
licenseTiers |
useLicenseStore().tiers[] |
❌(启动 fetch) |
2.3 vanilla 函数 → composable / store action 映射
| demo 函数 | 迁移目标 | 类型 |
|---|---|---|
switchStage(key) |
useStageStore().switchTo(key) |
store action |
toggleLock() |
useLayoutStore().toggleLock() |
store action |
setDrawerDockPos(side, pos) |
useDrawerDock().setDockPos(side, pos) |
composable |
setDrawerRatio(side, ratio) |
useDrawerDock().setRatio(side, ratio) |
composable |
setActiveDockIcon(side, key) |
useLayoutStore().setActiveIcon(side, key) |
store action |
setBottomTab(key) |
useLayoutStore().setBottomTab(key) |
store action |
onResizeStart(e, side) |
useWindowResize().startResize(e, side) |
composable |
splitDrawer(side, dir) |
useDrawerDock().split(side, dir) |
composable |
openTuningDialog(module, label) |
useTuningDialog().open(module, label) |
composable |
closeTuningDialog() |
useTuningDialog().close() |
composable |
minimizeTuningDialog() |
useTuningDialog().minimize() |
composable |
saveLayout() (debounce 300ms) |
Pinia plugin 自动 | plugin |
loadLayout() |
Pinia plugin 自动 | plugin |
showToast(msg) |
useToast().show(msg) (VueUse 或自实现) |
composable |
setTheme(key) |
useThemeStore().setCurrent(key) |
store action |
handleStageMessage(event) |
useStageBridge().on(type, handler) |
composable |
postToStage(type, data) |
useStageBridge().send(type, data) |
composable |
2.4 事件监听 → Vue 模板映射
| demo addEventListener | Vue 等价 |
|---|---|
pill.addEventListener('click', () => switchStage(key)) |
<Pill @click="stage.switchTo(key)"> |
dockIcon.addEventListener('click', ...) |
<DockIcon @click="layout.setActiveIcon(...)"> |
bottomTab.addEventListener('click', ...) |
<BottomTabBtn @click="layout.setBottomTab(...)"> |
window.addEventListener('message', handleStageMessage) |
useStageBridge() 内部 onMounted 注册 |
resizer.addEventListener('mousedown', onResizeStart) |
<Resizer @mousedown="windowResize.start"> |
window.addEventListener('resize', updateLayout) |
VueUse 的 useWindowSize() 替代 |
2.5 CSS 类切换 → Vue :class 映射
| demo DOM 操作 | Vue 等价 |
|---|---|
ide.classList.toggle('locked') |
:class="{ locked: layout.locked }" |
pane.classList.add('active') |
:class="{ active: bottom.activeKey === 'build' }" |
drawer.classList.add('show') |
:class="{ show: layout.drawerLeft.visible }" |
dockIcon.classList.toggle('active') |
:class="{ active: layout.drawerLeft.activeKey === 'project' }" |
3. iframe + postMessage → composable 映射
3.1 demo 实现(散落式)
// index.html ~1500 行附近
window.addEventListener('message', (e) => {
const msg = e.data
if (msg.type === 'stage-ready') { ... }
else if (msg.type === 'toolbar-inject') { ... }
else if (msg.type === 'inspector-inject') { ... }
// ...
})
function postToStage(type, data) {
stageFrame.contentWindow.postMessage({ type, ...data }, '*')
}
3.2 Vue 改造(composable 集中)
// composables/useStageBridge.ts
import type { StageMessage, ShellMessage } from '@/types/stage-bridge'
export function useStageBridge(stageFrameRef: Ref<HTMLIFrameElement | null>) {
const handlers = new Map<string, (msg: any) => void>()
function on<T extends StageMessage['type']>(
type: T,
handler: (msg: Extract<StageMessage, { type: T }>) => void
) { handlers.set(type, handler as any) }
function send<T extends ShellMessage['type']>(
type: T,
data: Omit<Extract<ShellMessage, { type: T }>, 'type'>
) {
stageFrameRef.value?.contentWindow?.postMessage({ type, ...data }, '*')
}
function onMessage(e: MessageEvent) {
if (e.source !== stageFrameRef.value?.contentWindow) return // 来源校验
const msg = e.data as StageMessage
handlers.get(msg.type)?.(msg)
}
onMounted(() => window.addEventListener('message', onMessage))
onUnmounted(() => window.removeEventListener('message', onMessage))
return { on, send }
}
3.3 在组件中使用
// components/stage/StageFrame.vue
const frameRef = ref<HTMLIFrameElement | null>(null)
const bridge = useStageBridge(frameRef)
const layout = useLayoutStore()
const stageBar = useStageBarStore()
bridge.on('toolbar-inject', (msg) => stageBar.setButtons(msg.buttons))
bridge.on('inspector-inject', (msg) => layout.setInspectorHtml(msg.html, msg.module))
bridge.on('module-properties-inject', (msg) => layout.setModulePropertiesHtml(msg.html, msg.module))
// ... 其余 5 类
详细 9 类协议见 40-postmessage-protocol.md。
4. localStorage 持久化映射
4.1 demo(手写 debounce)
function saveLayout() {
const data = {
stage: currentStage,
drawerLeft: { visible, dockPos, ratio, activeKey },
drawerRight: { ... },
bottom: { ... },
locked: ide.classList.contains('locked'),
theme: currentTheme
}
localStorage.setItem('xistudio-layout-v1', JSON.stringify(data))
}
const debouncedSave = debounce(saveLayout, 300)
4.2 Vue(Pinia plugin · 自动)
// stores/layout.ts
export const useLayoutStore = defineStore('layout', {
state: () => ({
locked: true,
drawerLeft: { visible: false, dockPos: 'left', ratio: '1/4', activeKey: null },
drawerRight: { visible: false, dockPos: 'right', ratio: '1/4', activeKey: null },
bottom: { visible: false, ratio: '1/3', activeKey: 'build' },
}),
persist: {
key: 'xistudio-layout-v1', // ⚠️ 必须与 demo 一致以兼容用户旧布局
paths: ['locked', 'drawerLeft', 'drawerRight', 'bottom'],
debug: false,
},
})
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
⚠️ 兼容性关键:localStorage key 必须沿用
xistudio-layout-v1,字段结构尽量保持一致。Pinia 持久化插件会自动 JSON 序列化/反序列化,不需要手写 debounce(插件内部已优化)。
5. 路由表(Vue Router 4)
// router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router'
import { useLicenseStore } from '@/stores/license'
const routes = [
{ path: '/', redirect: '/xilink' },
{
path: '/:stage(xilink|xitune|xiforge|xitest)',
name: 'stage',
component: () => import('@/components/shell/XiStudioShell.vue'),
meta: { license: (route: any) => `${route.params.stage}-pro` },
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const lic = useLicenseStore()
const requiredFn = to.meta.license as ((r: any) => string) | undefined
if (requiredFn) {
const required = requiredFn(to)
if (required && !lic.has(required)) {
// toast + 不阻塞,让 Stage 灰显
console.warn(`License "${required}" not granted; entering with read-only fallback`)
}
}
next()
})
⚠️ 当前 demo 4 Pill 全解锁是开发期开关位(commit
808531d),Vue 项目对接 license 守卫后会按useLicenseStore真实状态控制。
6. 主题系统映射
| demo 实现 | Vue 实现 |
|---|---|
<div class="ide" data-theme="default"> |
<div class="ide" :data-theme="theme.current"> |
| 6 主题 popover:DOM click 切换 attr | <ThemePicker @select="theme.setCurrent"> |
CSS 变量:--accent, --xi-light, --ok 等在 codex.css 中按 [data-theme="xxx"] 选择器定义 |
不动 codex.css,仅 Vue 切换 attr |
// stores/theme.ts
export const useThemeStore = defineStore('theme', {
state: () => ({ current: 'default' as ThemeKey }),
actions: { setCurrent(key: ThemeKey) { this.current = key } },
persist: { key: 'xistudio-theme-v1' },
})
7. 边界 & 反模式(不要做这些)
| ❌ 不要 | ✅ 应该 |
|---|---|
| 把 codex.css 拆成 SCSS module | 整个文件 import 为全局 CSS |
| 把 Stage 内部业务(节点、SVG、KPI)迁移到 Vue 组件 | iframe 沿用 stage-*.html |
用 .activity-icon 类名 |
严格用 .dock-icon(codex.css 选择器要求) |
直接 display:block 切换 view-pane |
用 :class="{ active: ... }" 切换 |
用新 localStorage key(如 xistudio-vue-layout) |
沿用 xistudio-layout-v1 兼容旧用户 |
在多处散落 addEventListener('message', ...) |
集中在 useStageBridge() composable |
postMessage 数据不带 stage 字段 |
9 类协议全部带 stage: 'xilink'\|... |
| 改 postMessage 协议但不写 ADR | 任何协议变更必须新建 ADR |
8. 改造对照清单(智能体逐项打勾)
- 创建
src/styles/xistudio-codex.css(从 demo 1:1 复制) - 创建
public/stages/(4 个 stage HTML 1:1 复制) - 创建
src/types/stage-bridge.ts(9 类协议 TS 类型) - 创建 4 个 Pinia store
- 创建 4 个 composable
- 创建 6 个 Shell 子组件(MenuBar / StageBar / SubTabs / ActivityBar / Drawer / BottomPanel / StatusBar)
- 创建
XiStudioShell.vue根组件(grid-template-areas) - 创建
StageFrame.vue+ bridge 接入 - 创建
TuningDialog.vue+<Teleport> - 配置 Vue Router 4 + license 守卫
- 配置 Pinia + persistedstate plugin
- 跑 Phase 7 验收
v1.0 · 2026-05-17 · D4 前端改造手册 · 架构映射 · 配套架构 v1.2.2-impl §12