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.activeKey→useLayoutStore().drawerLeft.activeKey -
drawerLeft.dataset.dockPos→useLayoutStore().drawerLeft.dockPos -
drawerLeft.dataset.ratio→useLayoutStore().drawerLeft.ratio -
bottom.dataset.activeKey→useLayoutStore().bottom.activeKey -
currentTheme→useThemeStore().current -
tuningDialogState→useTuningDialogStore() -
licenseTiers→useLicenseStore() -
saveLayout()debounce 手写 → Pinia plugin 自动 -
loadLayout()启动还原 → Pinia plugin 自动
v1.0 · 2026-05-17 · D4 前端改造手册 · Pinia 状态管理 · 配套 v1.2.2-impl §12.4