跳转至

50 · Pinia 状态管理设计

📌 本文目标:定义 4 个核心 Pinia store 的完整 state/getters/actions/persist 契约,前端智能体可直接复制到 src/stores/


1. Store 总览

Store 职责 持久化 localStorage key
useLayoutStore 窗口管理(locked/Drawer/Bottom)+ Stage 注入槽位 xistudio-layout-v1(兼容 demo)
useStageStore 当前 Stage + SubTab + iframe 状态机 ✅(仅 current/subtab) 同上
useLicenseStore 容器/Stage/Add-on 三元 license 启动 fetch
useThemeStore 6 主题切换 xistudio-theme-v1
useStageBarStore StageBar 工具组按钮(由 Stage 注入) 会话级
useTuningDialogStore 跨 Stage Tuning Dialog 浮窗 会话级

说明:除 4 个核心 store 外,配套了 2 个会话级 store(useStageBarStore / useTuningDialogStore)以便 §2 中 stores 间无循环依赖。


2. useLayoutStore(核心 · 窗口管理)

2.1 完整定义

// src/stores/layout.ts
import { defineStore } from 'pinia'
import type { StageKey } from '@/types/stage-bridge'

export type DockSide = 'left' | 'right'
export type DockPos = 'left' | 'right' | 'top' | 'bottom'
export type DockRatio = '1/2' | '1/3' | '1/4'
export type BottomTabKey = 'build' | 'problems' | 'terminal' | 'log' | 'properties'

export interface DrawerState {
  visible: boolean
  dockPos: DockPos
  ratio: DockRatio
  activeKey: string | null   // 当前激活的 dock-icon key
}

export interface BottomState {
  visible: boolean
  ratio: DockRatio
  activeKey: BottomTabKey
}

export const useLayoutStore = defineStore('layout', {
  state: () => ({
    // —— 持久化字段 ——
    locked: true as boolean,
    drawerLeft: {
      visible: false,
      dockPos: 'left' as DockPos,
      ratio: '1/4' as DockRatio,
      activeKey: null,
    } as DrawerState,
    drawerRight: {
      visible: false,
      dockPos: 'right' as DockPos,
      ratio: '1/4' as DockRatio,
      activeKey: null,
    } as DrawerState,
    bottom: {
      visible: false,
      ratio: '1/3' as DockRatio,
      activeKey: 'build' as BottomTabKey,
    } as BottomState,

    // —— 会话级字段(不持久化)——
    stageStatusHtml: '' as string,           // #stageStatusSlot
    stageHintHtml: '' as string,             // #stageHintSlot
    inspectorHtml: '' as string,             // 右 Drawer Inspector
    inspectorModule: '' as string,
    modulePropertiesHtml: '' as string,      // Bottom 属性 tab
    modulePropertiesModule: '' as string,
    drawerLeftTitle: '' as string,
    drawerLeftSubtitle: '' as string,
    drawerRightTitle: '' as string,
    drawerRightSubtitle: '' as string,
  }),

  getters: {
    /** 计算 grid-template-columns(根据 Drawer dockPos + ratio)*/
    gridColumns: (s) => {
      const left = s.drawerLeft.visible && s.drawerLeft.dockPos === 'left' ? ratioToFr(s.drawerLeft.ratio) : '0fr'
      const right = s.drawerRight.visible && s.drawerRight.dockPos === 'right' ? ratioToFr(s.drawerRight.ratio) : '0fr'
      return `${left} 1fr ${right}`
    },
    gridRows: (s) => {
      const top = (s.drawerLeft.visible && s.drawerLeft.dockPos === 'top') ||
                  (s.drawerRight.visible && s.drawerRight.dockPos === 'top')
                ? ratioToFr(s.drawerLeft.ratio) : '0fr'
      const bottom = s.bottom.visible ? ratioToFr(s.bottom.ratio) : '0fr'
      return `auto ${top} 1fr ${bottom} auto`
    },
  },

  actions: {
    // 锁定模式
    toggleLock() { this.locked = !this.locked },
    setLocked(v: boolean) { this.locked = v },

    // Drawer 控制
    setDrawerVisible(side: DockSide, visible: boolean) {
      const drawer = side === 'left' ? this.drawerLeft : this.drawerRight
      drawer.visible = visible
    },
    setDockPos(side: DockSide, pos: DockPos) {
      const drawer = side === 'left' ? this.drawerLeft : this.drawerRight
      drawer.dockPos = pos
    },
    setRatio(side: DockSide, ratio: DockRatio) {
      const drawer = side === 'left' ? this.drawerLeft : this.drawerRight
      drawer.ratio = ratio
    },
    setActiveIcon(side: DockSide, key: string | null) {
      const drawer = side === 'left' ? this.drawerLeft : this.drawerRight
      drawer.activeKey = key
    },

    // Bottom 控制
    setBottomVisible(visible: boolean) { this.bottom.visible = visible },
    setBottomRatio(ratio: DockRatio) { this.bottom.ratio = ratio },
    setBottomTab(key: BottomTabKey) {
      this.bottom.activeKey = key
      if (!this.bottom.visible) this.bottom.visible = true
    },

    // 槽位注入(postMessage handler 调用)
    setStageStatusHtml(html: string) { this.stageStatusHtml = html },
    setStageHintHtml(html: string) { this.stageHintHtml = html },
    setInspectorHtml(html: string, module: string) {
      this.inspectorHtml = html
      this.inspectorModule = module
    },
    setModulePropertiesHtml(html: string, module: string) {
      this.modulePropertiesHtml = html
      this.modulePropertiesModule = module
    },
    setDrawerTitle(side: DockSide, title: string, subtitle?: string) {
      if (side === 'left') {
        this.drawerLeftTitle = title
        this.drawerLeftSubtitle = subtitle ?? ''
      } else {
        this.drawerRightTitle = title
        this.drawerRightSubtitle = subtitle ?? ''
      }
    },

    // 槽位清空(Stage 切换时调用)
    clearStageStatusHtml() { this.stageStatusHtml = '' },
    clearStageHintHtml() { this.stageHintHtml = '' },
    clearInspectorHtml() {
      this.inspectorHtml = ''
      this.inspectorModule = ''
    },
    clearModulePropertiesHtml() {
      this.modulePropertiesHtml = ''
      this.modulePropertiesModule = ''
    },
  },

  persist: {
    key: 'xistudio-layout-v1',  // ⚠️ 沿用 demo key 兼容旧用户
    paths: ['locked', 'drawerLeft', 'drawerRight', 'bottom'],
  },
})

// 辅助函数
function ratioToFr(r: DockRatio): string {
  return r === '1/2' ? '1fr' : r === '1/3' ? '0.5fr' : '0.33fr'
}

2.2 持久化字段对照(与 demo 兼容)

Pinia 字段 demo localStorage 字段 兼容性
locked locked
drawerLeft.visible/dockPos/ratio/activeKey drawerLeft.{visible,dockPos,ratio,activeKey}
drawerRight.* drawerRight.*
bottom.visible/ratio/activeKey bottom.{visible,ratio,activeKey}

⚠️ 字段嵌套结构必须与 demo 完全一致,否则用户从 demo 切换到 Vue 项目时布局会丢失。


3. useStageStore

// src/stores/stage.ts
import { defineStore } from 'pinia'
import type { StageKey } from '@/types/stage-bridge'

export type StageState = 'idle' | 'loading' | 'frame-loaded' | 'ready' | 'switching'

export const useStageStore = defineStore('stage', {
  state: () => ({
    current: 'xilink' as StageKey,
    subtab: null as string | null,
    state: 'idle' as StageState,
    version: null as string | null,
  }),

  getters: {
    isReady: (s) => s.state === 'ready',
  },

  actions: {
    switchTo(key: StageKey) {
      if (this.current === key) return
      this.state = 'switching'
      this.current = key
      this.subtab = null  // 切 Stage 时重置 SubTab
      this.state = 'loading'
    },
    setSubtab(subtab: string) { this.subtab = subtab },
    markFrameLoaded() { this.state = 'frame-loaded' },
    markReady(stage: StageKey, version: string) {
      if (stage === this.current) {
        this.state = 'ready'
        this.version = version
      }
    },
    reset() {
      this.state = 'idle'
      this.version = null
    },
  },

  persist: {
    key: 'xistudio-stage-v1',
    paths: ['current', 'subtab'],
  },
})

4. useLicenseStore

// src/stores/license.ts
import { defineStore } from 'pinia'
import type { StageKey } from '@/types/stage-bridge'

export type LicenseTier = 'community' | 'pro' | 'enterprise'
export type LicenseKey = string  // 'xilink-pro' | 'xitune-pro' | 'xitune-reverse' | 'ximind-pro' ...

export const useLicenseStore = defineStore('license', {
  state: () => ({
    tier: 'community' as LicenseTier,
    keys: [] as LicenseKey[],     // 已授权的 license key 列表
    expiresAt: null as string | null,   // ISO 日期字符串
    daysLeft: 0 as number,
    fetched: false as boolean,
  }),

  getters: {
    /** 是否拥有某个 license */
    has: (s) => (key: LicenseKey) => s.keys.includes(key),

    /** 是否可进入某 Stage */
    canEnter: (s) => (stage: StageKey) => {
      // Community 默认含 xilink-view-only(只读)
      if (stage === 'xilink' && s.keys.includes('xilink-view-only')) return true
      return s.keys.includes(`${stage}-pro`)
    },

    /** 启动后默认 Stage(按 license 推断)*/
    defaultStage: (s) => {
      const enabled: StageKey[] = (['xilink', 'xitune', 'xiforge', 'xitest'] as const)
        .filter((stage) => s.keys.includes(`${stage}-pro`) || (stage === 'xilink' && s.keys.includes('xilink-view-only')))
      if (enabled.length === 1) return enabled[0]
      return 'xilink'  // 默认
    },
  },

  actions: {
    async fetch() {
      // TODO: 替换为真实 API
      const data = await mockFetchLicense()
      this.tier = data.tier
      this.keys = data.keys
      this.expiresAt = data.expiresAt
      this.daysLeft = computeDaysLeft(data.expiresAt)
      this.fetched = true
    },
    reset() {
      this.tier = 'community'
      this.keys = []
      this.expiresAt = null
      this.daysLeft = 0
      this.fetched = false
    },
  },

  // 不持久化(每次启动 fetch)
})

async function mockFetchLicense() {
  return {
    tier: 'pro' as LicenseTier,
    keys: ['xilink-pro', 'xitune-pro', 'xiforge-pro', 'xitest-pro'],
    expiresAt: new Date(Date.now() + 30 * 86400_000).toISOString(),
  }
}

function computeDaysLeft(expiresAt: string | null): number {
  if (!expiresAt) return 0
  return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 86400_000))
}

⚠️ Mock 数据返回 4 Stage 全开是开发期默认(与当前 demo 4 Pill 全解锁 行为对齐)。生产环境必须接入真实后端 API。


5. useThemeStore

// src/stores/theme.ts
import { defineStore } from 'pinia'

export const THEME_KEYS = [
  'default',          // 玫瑰金
  'paper-warm',       // 纸面暖色
  'night-deep',       // 深夜蓝
  'silk-grey',        // 丝绸灰
  'forest-mist',      // 森林雾
  'cherry-blossom',   // 樱花粉
] as const
export type ThemeKey = typeof THEME_KEYS[number]

export const useThemeStore = defineStore('theme', {
  state: () => ({
    current: 'default' as ThemeKey,
  }),

  actions: {
    setCurrent(key: ThemeKey) {
      this.current = key
    },
    cycle() {
      const idx = THEME_KEYS.indexOf(this.current)
      this.current = THEME_KEYS[(idx + 1) % THEME_KEYS.length]
    },
  },

  persist: {
    key: 'xistudio-theme-v1',
  },
})

6. useStageBarStore(会话级)

// src/stores/stage-bar.ts
import { defineStore } from 'pinia'
import type { ToolbarButton } from '@/types/stage-bridge'

export const useStageBarStore = defineStore('stage-bar', {
  state: () => ({
    buttons: [] as ToolbarButton[],
  }),

  getters: {
    /** 按 group 分组 */
    groups: (s) => {
      const map = new Map<string, ToolbarButton[]>()
      for (const btn of s.buttons) {
        const g = btn.group ?? '默认'
        if (!map.has(g)) map.set(g, [])
        map.get(g)!.push(btn)
      }
      return Array.from(map.entries()).map(([id, buttons]) => ({ id, buttons }))
    },
  },

  actions: {
    setButtons(buttons: ToolbarButton[]) { this.buttons = buttons },
    clearButtons() { this.buttons = [] },
  },

  // 不持久化
})

7. useTuningDialogStore(会话级)

// src/stores/tuning-dialog.ts
import { defineStore } from 'pinia'

export interface TuningDialogState {
  visible: boolean
  minimized: boolean
  module: string
  label: string
  position: { x: number; y: number }
}

export const useTuningDialogStore = defineStore('tuning-dialog', {
  state: () => ({
    visible: false,
    minimized: false,
    module: '',
    label: '',
    position: { x: 200, y: 100 },
  }),

  actions: {
    open(module: string, label: string) {
      this.module = module
      this.label = label
      this.visible = true
      this.minimized = false
    },
    close() {
      this.visible = false
      this.minimized = false
    },
    minimize() { this.minimized = true },
    restore() { this.minimized = false },
    setPosition(x: number, y: number) { this.position = { x, y } },
  },

  // 不持久化
})

8. Pinia 配置(main.ts)

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import { router } from './router'
import './styles/xistudio-codex.css'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

// 启动时 fetch license
import { useLicenseStore } from '@/stores/license'
const license = useLicenseStore()
license.fetch()

9. Store 间协作模式

9.1 StageStore ↔ LayoutStore(解耦)

// 在 StageFrame.vue 中协调(不在 store 内部直接相互调用)
watch(() => stage.current, () => {
  layout.clearStageStatusHtml()
  layout.clearStageHintHtml()
  layout.clearInspectorHtml()
  layout.clearModulePropertiesHtml()
  stageBar.clearButtons()
})

9.2 LicenseStore ↔ StageStore(路由守卫)

// router/index.ts
router.beforeEach((to, from, next) => {
  const lic = useLicenseStore()
  const stage = useStageStore()
  const stageKey = to.params.stage as StageKey
  if (!lic.canEnter(stageKey)) {
    useToast().show(`License 未授权进入 ${stageKey}`)
    // 不阻塞,让 Stage 内部展示只读模式
  }
  stage.switchTo(stageKey)
  next()
})

9.3 ThemeStore ↔ Stage iframe(postMessage)

// 在 StageFrame.vue 中
const theme = useThemeStore()
const bridge = useStageBridge(frameRef)

watch(() => theme.current, (newTheme) => {
  bridge.send('theme', { theme: newTheme })  // ← 需要 ADR
})

10. Devtools 调试

Pinia 自带 Vue Devtools 集成,所有 store 在 Devtools 的 Pinia tab 中可见。建议:

  • 给每个 action 加 console.debug 标签:console.debug('[layout/setDockPos]', side, pos)
  • 持久化字段变更可通过 pinia.use(({ store }) => store.$subscribe(...)) 监听

11. 迁移 checklist(layout-demo → Pinia)

  • currentStage (window 全局) → useStageStore().current
  • ide.classList.contains('locked')useLayoutStore().locked
  • drawerLeft.dataset.activeKeyuseLayoutStore().drawerLeft.activeKey
  • drawerLeft.dataset.dockPosuseLayoutStore().drawerLeft.dockPos
  • drawerLeft.dataset.ratiouseLayoutStore().drawerLeft.ratio
  • bottom.dataset.activeKeyuseLayoutStore().bottom.activeKey
  • currentThemeuseThemeStore().current
  • tuningDialogStateuseTuningDialogStore()
  • licenseTiersuseLicenseStore()
  • saveLayout() debounce 手写 → Pinia plugin 自动
  • loadLayout() 启动还原 → Pinia plugin 自动

v1.0 · 2026-05-17 · D4 前端改造手册 · Pinia 状态管理 · 配套 v1.2.2-impl §12.4