跳转至

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