XiStudio v3 · 前端实现规格 (Implementation Spec)
本文档配套
v3-unified-macos.htmldemo。 目标读者:前端智能体(Cline / Cursor / 其他 AI 编码助手)。 现有代码基线:AlgoDepartment/04_development/frontend_vue3/(Vue 3 + Vite + Pinia + ECharts + TypeScript,42 个 .vue 组件已沉淀)。 首要原则:在大框架不变的前提下融合 macOS 设计风格,复用现有组件,不推倒重做。
0. 阅读顺序
按以下顺序逐章实现,每章可独立交付(每个一级 § 是一个 PR 单位):
| § | 模块 | 输入文件 | 产出 | 估算工时 |
|---|---|---|---|---|
| 1 | 设计 token + 主题系统 | v3-unified-macos.html (CSS) | src/styles/tokens.css + useTheme.ts |
1d |
| 2 | macOS 全局样式 | demo CSS | src/styles/macos.css |
0.5d |
| 3 | 顶部双行(菜单+工具栏) | demo + Toolbar.vue | MenuBar.vue + ToolBar.vue 重构 |
1d |
| 4 | 主 tab + 子 tab + License | demo JS (TAB_CONFIG) | MainTabs.vue + SubTabs.vue + useLicense.ts |
1.5d |
| 5 | 三栏主体(左/中/右) | LeftPanel.vue | WorkspaceShell.vue + LeftSidebar.vue + RightInspector.vue |
2d |
| 6 | 底部面板 | BottomPropertyPanel.vue | BottomPanel.vue(重构) |
0.5d |
| 7 | Tuning 多开浮窗 | 现有 12 个 *TuningDialog.vue | useTuningManager.ts + 浮窗管理 |
1.5d |
| 8 | 辅助产品悬浮窗 | demo aux-window | AuxWindow.vue + useAuxWindow.ts |
1d |
| 9 | License 隔离 + 路由 | demo TAB_CONFIG | router/index.ts + 4 个 tab 模块的懒加载 |
1d |
总工时估算:~10 人日(两个前端工程师 1 周)。
1. 设计 Token + 主题系统
1.1 文件位置
src/
├── styles/
│ ├── tokens.css ← 三色法典 + macOS 变量(主题无关)
│ ├── themes.css ← 6 套主题(A-F)的 CSS 变量定义
│ └── macos.css ← macOS 全局样式(滚动条/焦点环/按钮按下)
├── composables/
│ └── useTheme.ts ← ThemeEngine 的 Vue 组合式封装
└── stores/
└── theme.ts ← Pinia store(localStorage 持久化)
1.2 tokens.css(不可改 hex 的法典 + macOS 风格变量)
直接复制 v3-unified-macos.html 第 14–46 行 :root {} 块。禁止修改任何 --yin / --xi / --sheng / --night-* / --pearl / --paper / --L0~L5 / --mac-traffic-* 的 hex 值,这些是 brand-color-system v1.2 法典。
新增的 macOS 变量必须保留:
/* macOS 风格变量(主题无关) */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--mac-shadow-sm: 0 1px 2px rgba(0,0,0,.08), 0 1px 1px rgba(0,0,0,.04);
--mac-shadow-md: 0 4px 16px rgba(0,0,0,.12), 0 1px 4px rgba(0,0,0,.08);
--mac-shadow-lg: 0 12px 48px rgba(0,0,0,.24), 0 4px 16px rgba(0,0,0,.16);
--mac-traffic-red: #FF5F57;
--mac-traffic-yellow: #FEBC2E;
--mac-traffic-green: #28C840;
1.3 themes.css(6 套主题完整 token)
直接复制 demo 第 51–217 行的 6 个 [data-theme="X"] {} 块(A 玫瑰金/B 极光青/C 米纸/D 极简白/E 赛博紫黑/F 毛玻璃)。
关键变量(每个主题必须定义):
| 类别 | 变量名 | 用途 |
|---|---|---|
| 背景 | --bg-0/1/2/3 |
4 级背景层 |
| 前景 | --fg-0/1/2 |
3 级文字(主/次/弱) |
| 边线 | --line / --line-soft |
普通/弱边框 |
| 强调 | --accent / --accent-2 / --accent-fg |
主题强调色 |
| 状态 | --info / --ok / --warn / --err |
4 种状态色 |
| 阴影 | --shadow |
主阴影 |
| 画布 | --canvas-bg / --canvas-grid-color |
流图画布 |
| 节点 | --node-bg / --node-border |
流图节点 |
| 按钮 | --primary-btn-bg / --primary-btn-fg |
主按钮 |
| 全屏 | --hero-grad |
全屏背景渐变 |
| 面板 | --panel-bg / --bottom-bg / --statusbar-bg / --menubar-bg / --toolbar-bg |
5 种面板背景 |
| 毛玻璃 | --frosted |
backdrop-filter 值(仅主题 F 不为 none) |
| 焦点 | --focus-ring |
品牌色焦点环 |
1.4 useTheme.ts(Vue 3 组合式 API)
// src/composables/useTheme.ts
import { ref, watch, onMounted } from 'vue'
export type ThemeId = 'A' | 'B' | 'C' | 'D' | 'E' | 'F'
export interface ThemeMeta {
id: ThemeId
name: string
sub: string
group: '品牌组' | '审美组'
}
export const THEMES: ThemeMeta[] = [
{ id: 'A', name: '玫瑰金主导', sub: 'Yin Gold · 品牌默认', group: '品牌组' },
{ id: 'B', name: '极光青主导', sub: 'Sheng Cyan · 工程师风', group: '品牌组' },
{ id: 'C', name: '米纸浅色', sub: 'Paper Light · 白天模式', group: '品牌组' },
{ id: 'D', name: '极简白', sub: 'Linear / Notion 风', group: '审美组' },
{ id: 'E', name: '赛博紫黑', sub: 'Cyberpunk DAW', group: '审美组' },
{ id: 'F', name: '毛玻璃', sub: 'Vision OS', group: '审美组' },
]
const STORAGE_KEY = 'xistudio.v3.preferences.v1'
export function useTheme() {
const theme = ref<ThemeId>('A')
const fontScale = ref(100)
const density = ref(100)
function load() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
if (saved.theme) theme.value = saved.theme
if (saved.fontScale) fontScale.value = saved.fontScale
if (saved.density) density.value = saved.density
} catch {}
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
theme: theme.value,
fontScale: fontScale.value,
density: density.value,
}))
}
function apply() {
document.body.setAttribute('data-theme', theme.value)
document.documentElement.style.setProperty('--font-scale', String(fontScale.value / 100))
document.documentElement.style.setProperty('--density', String(density.value / 100))
}
function setTheme(id: ThemeId) { theme.value = id }
watch([theme, fontScale, density], () => { save(); apply() }, { immediate: false })
onMounted(() => { load(); apply() })
return { theme, fontScale, density, THEMES, setTheme }
}
1.5 stores/theme.ts(Pinia 替代方案,团队偏好哪个用哪个)
// src/stores/theme.ts
import { defineStore } from 'pinia'
export const useThemeStore = defineStore('theme', {
state: () => ({
theme: 'A' as 'A'|'B'|'C'|'D'|'E'|'F',
fontScale: 100,
density: 100,
}),
actions: {
setTheme(id: typeof this.theme) {
this.theme = id
this.persist()
this.apply()
},
apply() {
document.body.setAttribute('data-theme', this.theme)
document.documentElement.style.setProperty('--font-scale', String(this.fontScale / 100))
document.documentElement.style.setProperty('--density', String(this.density / 100))
},
persist() {
localStorage.setItem('xistudio.v3.preferences.v1', JSON.stringify(this.$state))
},
hydrate() {
try {
const saved = JSON.parse(localStorage.getItem('xistudio.v3.preferences.v1') || '{}')
Object.assign(this.$state, saved)
} catch {}
this.apply()
},
},
})
main.ts 启动时调用:useThemeStore().hydrate()。
2. macOS 全局样式
2.1 macos.css
复制 demo 的以下片段到 src/styles/macos.css:
- 字体栈(demo L228–229):
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", ... - 滚动条(demo L242–246):细化 8px + hover 才显示
- 焦点环(demo L249–253):
*:focus-visible用var(--focus-ring)而非系统蓝 - 按钮按下缩放(demo L257–262):
button:active { transform: scale(0.97); } - 红黄绿 traffic-light 三色按钮样式(demo L304–323):所有窗口/对话框头部复用
2.2 在 main.ts 中按顺序引入
3. 顶部双行(菜单 + 工具栏)
3.1 组件树
src/components/shell/
├── MenuBar.vue ← 第 1 行(28px):traffic-lights + logo + 9 菜单项 + 右侧 tag/demo-link
└── ToolBar.vue ← 第 2 行(44px):3-4 个 toolbar-group + 搜索框
3.2 MenuBar.vue 设计
Props: 无(顶级组件)。
Emits:
- menu-click(menu: 'file'|'edit'|'view'|'project'|'build'|'debug'|'tools'|'window'|'help')
- theme-trigger-click()
- traffic-click(color: 'red'|'yellow'|'green')
Template 骨架(参考 demo L1057–L1085):
- 左:.traffic-lights 三色按钮(点击 emit traffic-click,预留窗口控制接口)
- Logo ◆ XiStudio 用 --accent 色
- 9 个 .menu-item(文件/编辑/视图/项目/构建/调试/工具/窗口/帮助)
- 右:AI 状态 tag + 当前主题 tag + 🎨 切换 按钮 + v2 demo 链接(生产环境删除)
关键样式:背景 var(--menubar-bg) + backdrop-filter: var(--frosted),毛玻璃必备。
3.3 ToolBar.vue 设计(重构现有 Toolbar.vue)
Props:
interface Props {
isCompiling?: boolean
isRunning?: boolean
currentMode?: 'edit' | 'tune' | 'test' | 'deploy'
}
Emits:
- compile() / run() / stop() / flash()
- undo() / redo()
- mode-change(mode: 'tune'|'test'|'deploy')
- cmd-palette-open()(搜索框 focus 时触发)
4 个 .toolbar-group(demo L1090–L1116):
| Group | 按钮 | 图标 | Emit |
|---|---|---|---|
| 1 文件 | 新建/打开/保存 | 📄📂💾 | new/open/save |
| 2 构建 | 编译(primary)/运行/停止/烧录 | ⚙▶■⚡ | compile/run/stop/flash |
| 3 编辑 | 撤销/重做 | ↶↷ | undo/redo |
| 4 模式 | 调音/测试/部署 | 🎚🔍🚀 | mode-change |
右侧搜索框:.toolbar-search + 显示 ⌘K 提示,onfocus 触发命令面板。
4. 主 Tab + 子 Tab + License 隔离
4.1 路由设计(vue-router v4)
// src/router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{ path: '/', redirect: '/xistudio' },
{
path: '/xistudio',
component: () => import('@/views/xistudio/XiStudioWorkspace.vue'),
meta: { license: 'studio-pro', label: 'XiStudio (L4)', icon: '🎛' },
children: [
{ path: '', redirect: 'flow' },
{ path: 'flow', component: () => import('@/views/xistudio/sub/FlowView.vue') },
{ path: 'tune', component: () => import('@/views/xistudio/sub/TuneView.vue') },
{ path: 'sim', component: () => import('@/views/xistudio/sub/SimView.vue') },
{ path: 'deploy', component: () => import('@/views/xistudio/sub/DeployView.vue') },
],
},
{
path: '/xiforge',
component: () => import('@/views/xiforge/XiForgeWorkspace.vue'),
meta: { license: 'forge-pro', label: 'XiForge (L4)', icon: '🔨' },
children: [
{ path: '', redirect: 'design' },
{ path: 'design', component: () => import('@/views/xiforge/sub/DesignView.vue') },
{ path: 'source', component: () => import('@/views/xiforge/sub/SourceView.vue') },
{ path: 'sim', component: () => import('@/views/xiforge/sub/SimView.vue') },
],
},
{
path: '/xitune',
component: () => import('@/views/xitune/XiTuneWorkspace.vue'),
meta: { license: 'tune-basic', label: 'XiTune (L2)', icon: '🎚' },
children: [
{ path: '', redirect: 'chain' },
{ path: 'chain', component: () => import('@/views/xitune/sub/ChainView.vue') },
{ path: 'curve', component: () => import('@/views/xitune/sub/CurveView.vue') },
{ path: 'abx', component: () => import('@/views/xitune/sub/ABXView.vue') },
],
},
{
path: '/xiprobe',
component: () => import('@/views/xiprobe/XiProbeWorkspace.vue'),
meta: { license: 'probe-basic', label: 'XiProbe (L2)', icon: '📡' },
children: [
{ path: '', redirect: 'freq' },
{ path: 'freq', component: () => import('@/views/xiprobe/sub/FreqView.vue') },
{ path: 'thd', component: () => import('@/views/xiprobe/sub/THDView.vue') },
{ path: 'snr', component: () => import('@/views/xiprobe/sub/SNRView.vue') },
{ path: 'batch', component: () => import('@/views/xiprobe/sub/BatchView.vue') },
],
},
]
const router = createRouter({ history: createWebHashHistory(), routes })
// License 路由守卫
router.beforeEach((to, from, next) => {
const lic = useLicenseStore()
const required = to.meta.license as string | undefined
if (required && !lic.has(required)) {
showToast(`License "${required}" 未授权,无法进入 ${to.meta.label}`)
return next(false)
}
next()
})
export default router
关键点:
- 每个主 tab 是一个异步加载的 chunk(() => import(...)),未授权用户根本不下载该 tab 的 JS(满足"代码架构上做隔离"要求)。
- VST 开发场景 = 用户 license 仅含 forge-pro,启动后只能进入 /xiforge。
- 用 meta.license 而不是硬编码,方便后端动态下发。
4.2 useLicense composable / stores/license.ts
// src/stores/license.ts
import { defineStore } from 'pinia'
export type LicenseTier =
| 'studio-pro' | 'forge-pro' | 'tune-basic' | 'probe-basic'
| 'xicloud-enterprise' | 'ximind-pro'
export const useLicenseStore = defineStore('license', {
state: () => ({
tiers: [] as LicenseTier[],
expiresAt: null as Date | null,
}),
getters: {
has: (s) => (tier: LicenseTier) => s.tiers.includes(tier),
enabledTabs: (s): Array<'xistudio'|'xiforge'|'xitune'|'xiprobe'> => {
const out: any[] = []
if (s.tiers.includes('studio-pro')) out.push('xistudio')
if (s.tiers.includes('forge-pro')) out.push('xiforge')
if (s.tiers.includes('tune-basic')) out.push('xitune')
if (s.tiers.includes('probe-basic')) out.push('xiprobe')
return out
},
},
actions: {
async fetch() {
const res = await fetch('/api/license')
const data = await res.json()
this.tiers = data.tiers
this.expiresAt = new Date(data.expires_at)
},
},
})
4.3 MainTabs.vue(demo 第 1140–1158 行)
Template:v-for 渲染路由配置(含 meta.license / meta.label / meta.icon),过滤未授权 tab 显示为 disabled 灰显态。
响应式状态:通过 useRoute() 取当前路径前缀决定 active。点击调用 router.push('/' + tabId)。
4.4 SubTabs.vue(demo 第 1163–1166 行 + JS L1551)
Template:v-for 渲染当前路由的 children(去掉 redirect 项),每个子 tab 是一个 <router-link> 或 <button @click="router.push(child.path)">。
关键样式:
.sub-tab.active {
color: var(--accent);
border-bottom: 2px solid var(--tab-active-line);
font-weight: 500;
}
5. 三栏主体(左 + 中 + 右)
5.1 总壳 WorkspaceShell.vue
每个 tab 的 *Workspace.vue(4 个)都使用同一个 WorkspaceShell.vue 作为骨架,通过 slot 注入差异内容:
<!-- src/components/shell/WorkspaceShell.vue -->
<template>
<div class="workspace-shell">
<slot name="left" /> <!-- 左侧栏 240px -->
<slot name="center" /> <!-- 中央画布 1fr -->
<slot name="right" /> <!-- 右侧栏 320px -->
</div>
</template>
<style scoped>
.workspace-shell {
display: grid;
grid-template-columns: 240px 1fr 320px;
grid-template-rows: 1fr;
height: 100%;
/* 注意: WorkspaceShell 自己 height:100%, 由父级 body-wrap 用 absolute 定位让出底部 220px */
}
@media (max-width: 1280px) {
.workspace-shell { grid-template-columns: 220px 1fr 280px; }
}
</style>
5.2 LeftSidebar.vue(macOS Sidebar 风)
重构现有 LeftPanel.vue 的方式:保留现有 5 tab 的内部组件(ProjectPanel / ModuleLibraryPanel / ConnectionPanel / ProfileSidebar / AudioEnginePanel),但外壳重写为 macOS 风格 sidebar。
新组件结构:
src/components/shell/LeftSidebar.vue ← 新外壳
├── (复用) ProjectPanel.vue ← 工程 tab 内容
├── (复用) ModuleLibraryPanel.vue ← IP库 tab 内容
├── (复用) ConnectionPanel.vue ← 连接 tab 内容
├── (复用) ProfileSidebar.vue ← 音效 tab 内容
└── (复用) AudioEnginePanel.vue ← 引擎 tab 内容
Props:
interface Props {
tabs: Array<{ id: string; label: string; icon: string }>
modelValue: string // 当前 active tab id
}
Emits: update:modelValue(id)
Template 骨架(参考 demo L1188–L1197):
<template>
<div class="panel-left">
<div class="sidebar-tabs">
<div v-for="t in tabs" :key="t.id"
:class="['sidebar-tab', { active: modelValue === t.id }]"
@click="$emit('update:modelValue', t.id)">
<span class="ico">{{ t.icon }}</span>{{ t.label }}
</div>
</div>
<div class="sidebar-body">
<slot :active="modelValue" />
</div>
</div>
</template>
用法(XiStudio Workspace 内):
<LeftSidebar
:tabs="[
{ id: 'proj', label: '工程', icon: '📁' },
{ id: 'lib', label: 'IP库', icon: '🧩' },
{ id: 'conn', label: '连接', icon: '🔌' },
{ id: 'profile', label: '音效', icon: '🎵' },
{ id: 'engine', label: '引擎', icon: '⚙' },
]"
v-model="leftTab"
>
<template #default="{ active }">
<ProjectPanel v-show="active === 'proj'" />
<ModuleLibraryPanel v-show="active === 'lib'" />
<ConnectionPanel v-show="active === 'conn'" />
<ProfileSidebar v-show="active === 'profile'" />
<AudioEnginePanel v-show="active === 'engine'" />
</template>
</LeftSidebar>
5.3 RightInspector.vue
结构同 LeftSidebar.vue,但 tab 数量与内容随主 tab 而异:
| 主 tab | Right tabs |
|---|---|
| XiStudio | 仿真 📈 / 检视 🔍 / A/B 🅰🅱 / 辅助 ⚙ |
| XiForge | 布局 📐 / 样式 🎨 / 绑定 🔗 / 事件 ⚡ |
| XiTune | 频响 📈 / RTA 📊 / A/B 🅰🅱 |
| XiProbe | 结果 📊 / 信号源 🌊 / 辅助 ⚙ |
仿真示波器卡片:使用 ECharts 实例(项目已有依赖),不要继续用 demo 的静态 SVG。组件 ScopeCard.vue:
<!-- ScopeCard.vue -->
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts/core'
const props = defineProps<{
title: string
badge?: string
type: 'freq' | 'rta' | 'wave' | 'phase'
data: number[]
}>()
const chartEl = ref<HTMLElement>()
let chart: echarts.ECharts | null = null
function makeOption() {
return {
grid: { left: 4, right: 4, top: 6, bottom: 6 },
xAxis: { show: false, type: 'category', data: props.data.map((_, i) => i) },
yAxis: { show: false, type: 'value' },
series: [{ type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 1.4 }, data: props.data }],
color: [getComputedStyle(document.body).getPropertyValue('--accent').trim()],
}
}
onMounted(() => {
chart = echarts.init(chartEl.value!)
chart.setOption(makeOption())
})
watch(() => props.data, () => chart?.setOption(makeOption()))
</script>
<template>
<div class="scope-card">
<div class="cap"><span>{{ title }}</span><span class="badge">{{ badge }}</span></div>
<div ref="chartEl" style="width:100%;height:78px"></div>
</div>
</template>
5.4 中央画布
每个 tab 的中央画布是完全独立的组件,不共享:
| Tab | 中央组件 | 复用现有 |
|---|---|---|
| XiStudio | FlowCanvas.vue(流图) |
复用 LinkEditor.vue + WorkspacePanel.vue |
| XiForge | UIDesignerCanvas.vue(UI 设计器) |
新写,参考 v2-xiforge.html |
| XiTune | TuningChainCanvas.vue(调音链) |
新写,调用 *TuningDialog 系列 |
| XiProbe | ProbeTestCanvas.vue(测试图表 + ECharts) |
新写 |
6. 底部面板
6.1 BottomPanel.vue(重构 BottomPropertyPanel.vue)
关键变化:从原来的 view-pane 内子组件 → 跨 tab 共享的全局组件,absolute 定位在 body-wrap 底部 220px(参考 demo CSS L668–L678)。
Tab 列表(demo L1517–L1525):
- 📋 Module 属性(默认)
- ⚙ 编译 / 📜 日志 / ⚠ 问题 / $ 终端 / 🤖 XiMind(共用 LogPanel.vue)
- { } 生成代码(仅 XiForge tab 显示,复用 id="codeTab" 逻辑)
- 🪟 Tuning 托盘按钮(右侧 tray-btn)
Props:
interface Props {
selectedNode?: NodeRef // 来自 stores/workspace
showCodeTab?: boolean // computed: route.path.startsWith('/xiforge')
}
Emits: tray-toggle()
7. Tuning 多开浮窗(核心交互)
7.1 组件树
src/composables/useTuningManager.ts ← Map<id, TuningInstance> 全局管理
src/components/tuning/
├── TuningContainer.vue ← 全局 mounted 在 App.vue,渲染所有打开的浮窗
├── TuningDialogShell.vue ← macOS 风格外壳(traffic-lights + 标题栏 + 拖拽)
└── (复用现有 12 个) *TuningDialog.vue ← 内容(GainTuningDialog / GEQTuningDialog 等)
7.2 useTuningManager.ts
// src/composables/useTuningManager.ts
import { reactive, markRaw } from 'vue'
import type { Component } from 'vue'
export interface TuningInstance {
id: string // 唯一 ID, e.g. 'PEQ#1'
title: string // 显示名, e.g. 'PEQ × 8 段'
component: Component // 内部内容组件
props?: Record<string, any>
x: number; y: number
zIndex: number
minimized: boolean
}
const tunings = reactive(new Map<string, TuningInstance>())
let zCounter = 50
export function useTuningManager() {
function open(id: string, title: string, component: Component, props?: any, fromEl?: HTMLElement) {
if (tunings.has(id)) {
const t = tunings.get(id)!
t.minimized = false
t.zIndex = ++zCounter
return
}
const offset = tunings.size * 30
let x = 380 + offset, y = 180 + offset
if (fromEl) {
const r = fromEl.getBoundingClientRect()
x = Math.min(r.right + 20, window.innerWidth - 340)
y = Math.max(r.top - 10, 130)
}
tunings.set(id, {
id, title, component: markRaw(component), props,
x, y, zIndex: ++zCounter, minimized: false,
})
}
function close(id: string) { tunings.delete(id) }
function minimize(id: string) {
const t = tunings.get(id); if (t) t.minimized = true
}
function show(id: string) {
const t = tunings.get(id); if (t) { t.minimized = false; t.zIndex = ++zCounter }
}
function bringToFront(id: string) {
const t = tunings.get(id); if (t) t.zIndex = ++zCounter
}
function closeAll() { tunings.clear() }
return { tunings, open, close, minimize, show, bringToFront, closeAll }
}
7.3 TuningDialogShell.vue(macOS 外壳)
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps<{
id: string; title: string
x: number; y: number; zIndex: number
}>()
const emit = defineEmits<{
close: []
minimize: []
'update:position': [x: number, y: number]
'bring-to-front': []
}>()
const dialogEl = ref<HTMLElement>()
function startDrag(e: MouseEvent) {
if ((e.target as HTMLElement).tagName === 'BUTTON') return
if ((e.target as HTMLElement).classList.contains('traffic')) return
emit('bring-to-front')
const r = dialogEl.value!.getBoundingClientRect()
const ox = e.clientX - r.left, oy = e.clientY - r.top
document.body.style.userSelect = 'none'
function move(ev: MouseEvent) {
const x = Math.max(0, Math.min(window.innerWidth - 100, ev.clientX - ox))
const y = Math.max(28, Math.min(window.innerHeight - 60, ev.clientY - oy))
emit('update:position', x, y)
}
function up() {
document.removeEventListener('mousemove', move)
document.removeEventListener('mouseup', up)
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', move)
document.addEventListener('mouseup', up)
}
</script>
<template>
<div ref="dialogEl" class="tuning-dialog"
:style="{ left: x + 'px', top: y + 'px', zIndex }">
<div class="td-head" @mousedown="startDrag">
<div class="traffic-lights">
<div class="traffic red" @click="emit('close')"></div>
<div class="traffic yellow" @click="emit('minimize')"></div>
<div class="traffic green"></div>
</div>
<span class="td-title">🎚 {{ id }} · {{ title }}</span>
<button title="最小化" @click="emit('minimize')">─</button>
<button title="关闭" @click="emit('close')">×</button>
</div>
<div class="td-body"><slot /></div>
</div>
</template>
7.4 TuningContainer.vue(全局浮窗渲染层)
<script setup lang="ts">
import { useTuningManager } from '@/composables/useTuningManager'
import TuningDialogShell from './TuningDialogShell.vue'
const { tunings, close, minimize, bringToFront } = useTuningManager()
function updatePos(id: string, x: number, y: number) {
const t = tunings.get(id); if (t) { t.x = x; t.y = y }
}
</script>
<template>
<Teleport to="body">
<TuningDialogShell
v-for="[id, t] in tunings"
:key="id"
v-show="!t.minimized"
:id="t.id" :title="t.title"
:x="t.x" :y="t.y" :z-index="t.zIndex"
@close="close(id)"
@minimize="minimize(id)"
@update:position="(x, y) => updatePos(id, x, y)"
@bring-to-front="bringToFront(id)"
>
<component :is="t.component" v-bind="t.props" />
</TuningDialogShell>
</Teleport>
</template>
挂载位置:在 App.vue 根节点最后一层,紧邻 <RouterView /> 之后。
7.5 触发示例(XiStudio 流图节点双击打开)
<script setup lang="ts">
import { useTuningManager } from '@/composables/useTuningManager'
import GEQTuningDialog from '@/components/GEQTuningDialog.vue'
const { open } = useTuningManager()
function onNodeDblClick(node: { id: string; type: string }, ev: MouseEvent) {
if (node.type === 'GEQ') open(node.id, 'GEQ × 31', GEQTuningDialog, { nodeId: node.id }, ev.target as HTMLElement)
}
</script>
7.6 Tuning 托盘 + 计数
BottomPanel.vue 右侧 tray-btn:
<button class="tray-btn" @click="trayOpen = !trayOpen">
🪟 Tuning <span class="count">{{ tunings.size }}</span>
</button>
托盘弹出层渲染 tunings.values(),每行支持:显示/最小化/关闭。
8. 辅助产品悬浮窗
8.1 组件设计
辅助产品(XiCal/XiMic/XiBox/XiAmp/XiDSP/XiCore)= 小型可拖拽悬浮窗口,与 Tuning 浮窗共享拖拽/traffic-lights 模式。
src/composables/useAuxWindow.ts
src/components/aux/
├── AuxWindowShell.vue ← macOS 外壳
├── XiCalWindow.vue ← 麦克风校准
├── XiMicWindow.vue ← 麦克风测试
├── XiBoxWindow.vue ← 测试夹具
└── (按需扩展)
与 Tuning 的差别: - aux-window 是轻量级辅助(设备控制 + 状态),不像 Tuning 那样有大量参数滑块 - 默认位置在屏幕右下角而不是中央 - 不进入 Tuning 托盘(独立管理)
8.2 useAuxWindow.ts
import { reactive, markRaw } from 'vue'
import type { Component } from 'vue'
const auxWindows = reactive(new Map<string, {
id: string; title: string; component: Component
x: number; y: number; zIndex: number; open: boolean
}>())
let z = 150
export function useAuxWindow() {
function open(id: string, title: string, comp: Component) {
if (!auxWindows.has(id)) {
auxWindows.set(id, {
id, title, component: markRaw(comp),
x: window.innerWidth - 300, y: window.innerHeight - 280,
zIndex: ++z, open: true,
})
} else {
const w = auxWindows.get(id)!; w.open = true; w.zIndex = ++z
}
}
function close(id: string) {
const w = auxWindows.get(id); if (w) w.open = false
}
return { auxWindows, open, close }
}
9. 命令面板 + 全局快捷键
9.1 文件位置
src/composables/useCommandPalette.ts
src/components/global/
├── CommandPalette.vue
└── ShortcutHandler.vue ← 挂在 App.vue,无 UI,只绑定 keydown
9.2 命令注册表
// src/composables/useCommandPalette.ts
import { reactive } from 'vue'
export interface Command {
id: string
cat: '导航' | '主题' | '调音' | '辅助' | '构建' | '文件'
name: string
kbd?: string
action: () => void
}
const commands = reactive<Command[]>([])
export function registerCommand(cmd: Command) { commands.push(cmd) }
export function useCommands() { return commands }
主 tab 切换、主题切换、调音对话框、辅助窗口、编译/运行全部通过 registerCommand 注册(demo L1859–L1875 是完整范例)。
9.3 全局快捷键映射
| 快捷键 | 行为 |
|---|---|
| Ctrl+1 / Ctrl+2 / Ctrl+3 / Ctrl+4 | 切换主 tab(XiStudio / XiForge / XiTune / XiProbe) |
| Ctrl+K T | 打开主题选择 popover |
| Ctrl+Shift+P | 打开命令面板 |
| Ctrl+, | 打开设置(P2 实现) |
| F7 | 编译 |
| F5 | 运行仿真 |
| Shift+F5 | 停止 |
| Ctrl+S | 保存当前文件 |
| Esc | 关闭所有浮层(命令面板/popover/aux) |
10. 与现有 frontend_vue3 组件的映射
按行动分 3 类:
10.1 直接复用(不改)— 24 个
AtmosEngineTuningDialog / AudioEnginePanel / ChannelMapTuningDialog / CommonEQTuningDialog / ConnectionPanel / DelayTuningDialog / GainTuningDialog / GenericTuningDialog / GEQTuningDialog / LinkEditor / LogPanel / MDRCTuningDialog / MixerTuningDialog / ModuleLibraryPanel / ModuleTuningDialog / PresetPanel / ProfileSidebar / SinkTuningDialog / SourceDeviceDialog / SourceTuningDialog / ThirdPartyModuleDialog / VirtualBassTuningDialog / WorkspacePanel / LimiterTuningDialog
注意:所有 *TuningDialog.vue 现在改为只渲染内容,不再包含自己的标题栏 / 关闭按钮 / 拖拽 —— 这部分由新增的 TuningDialogShell.vue 统一接管。如果现有组件已经包含标题栏,需要剥离(约 1h/组件 × 12 个 = 1.5d 工作量)。
10.2 重写(新外壳,复用内部内容)— 5 个
| 旧组件 | 新组件 | 改造点 |
|---|---|---|
LeftPanel.vue |
LeftSidebar.vue |
macOS Sidebar 风外壳,内容用 slot 注入 |
Toolbar.vue |
ToolBar.vue |
4 个 toolbar-group 圆角组合 + 搜索框 |
BottomPropertyPanel.vue |
BottomPanel.vue |
跨 tab 共享 + 7 tab + 代码生成 pane |
FloatingPropertyPanel.vue |
TuningDialogShell.vue |
升级为通用浮窗外壳,traffic-lights |
PropertyPanel.vue |
(并入 RightInspector.vue slot 之一) |
Inspector 只是其中一个 right-tab |
10.3 新增 — 16 个
src/components/shell/
├── MenuBar.vue
├── ToolBar.vue (重构)
├── MainTabs.vue
├── SubTabs.vue
├── WorkspaceShell.vue
├── LeftSidebar.vue (重构)
├── RightInspector.vue
├── BottomPanel.vue (重构)
└── StatusBar.vue (现有可用,加 macOS 微调即可)
src/components/tuning/
├── TuningDialogShell.vue
└── TuningContainer.vue
src/components/aux/
├── AuxWindowShell.vue
├── XiCalWindow.vue
├── XiMicWindow.vue
└── XiBoxWindow.vue
src/components/global/
├── ThemePopover.vue
├── CommandPalette.vue
└── ShortcutHandler.vue
10.4 现有 TT* 控件(直接复用作为 XiForge 控件库素材)
TTButton / TTLabel / TTSlider / TTToggle / TTTabsWidget / TTLabelComboBoxWidget / TTProgressBarWidget / TTChartTableWidget / TTMixerWidget / TTGGEQWidget:这些是 XiForge UI 设计器的"基础控件库",前端智能体在实现 XiForge 的左控件库面板时直接 v-for 渲染即可(参考 demo data-side-pane="ctrls" 块)。
11. Pinia Store 总清单
| Store | 文件 | 关键 state | 关键 actions |
|---|---|---|---|
theme |
stores/theme.ts |
theme, fontScale, density | setTheme, hydrate |
license |
stores/license.ts |
tiers, expiresAt | fetch, has(tier), enabledTabs |
workspace |
stores/workspace.ts |
currentTab, currentSubtab, selectedNode | switchTab, selectNode |
project |
stores/project.ts |
projectFile, chains[], modules[] | load, save |
engine |
stores/engine.ts |
running, sampleRate, blockSize, latency, cpu, memPool | start, stop, restart |
connection |
stores/connection.ts |
devices[](含 led 状态) | scan, connect, disconnect |
profile |
stores/profile.ts |
activeProfile, list[], abState | load, save, abToggle |
tuning |
stores/tuning.ts 或 composables/useTuningManager.ts |
Map |
open/close/minimize/show |
auxWindow |
composables/useAuxWindow.ts |
Map |
open/close |
commands |
composables/useCommandPalette.ts |
commands[] | register, execute |
12. macOS 设计风格关键 CSS 实现
完整 CSS 已在 v3-unified-macos.html 第 7–940 行。前端智能体直接拷贝这一段到 src/styles/ 三个 css 文件即可。下表是 9 个必须正确实现的 macOS 关键点的速查:
| 设计点 | demo 行号 | 实现要点 |
|---|---|---|
| 1. 圆角 | L29–32 | --radius-sm/md/lg/xl 4 级,普通卡片 10px、浮窗 14px、引导 20px |
| 2. 毛玻璃 | L80,L271,L295 等 | backdrop-filter: var(--frosted),--frosted 仅主题 F 设为 blur(28px) saturate(180%),其他可设为 none 或保留 blur(20px) saturate(180%) |
| 3. 红黄绿三色按钮 | L304–323 | 三个 div.traffic.red/yellow/green,hover 加暗色叠加 |
| 4. SF Pro 字体栈 | L228–229 | -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", "PingFang SC", ... |
| 5. SFSymbols 风图标 | 全局 | 用 emoji 占位(📄📂💾⚙▶■⚡↶↷🎚🔍🚀),生产环境用 SVG 替换。禁止用 Material/Bootstrap icon |
| 6. 滚动条悬停才显 | L242–246 | *:hover::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); } |
| 7. 按钮按下缩放 | L257–262 | button:active { transform: scale(0.97); } + transition: transform .08s ease |
| 8. 焦点环品牌色 | L249–253 | *:focus-visible { box-shadow: var(--focus-ring); } 而非系统蓝 |
| 9. 主题 Pill Tab | L450–488 | 主 tab 使用 border-radius: 999px 胶囊,激活态填充 accent 实色 |
13. 主要交互逻辑速查
13.1 状态变更触发链路
用户点击主 tab
→ router.push('/xiforge')
→ MainTabs.vue 监听 useRoute(),更新 active 类
→ 路由懒加载 XiForgeWorkspace.vue (License 检查 → 渲染)
→ SubTabs.vue 自动渲染 children 子 tab
→ BottomPanel.vue 计算 showCodeTab=true(因为 xiforge)
→ useTuningManager 不变(浮窗跨 tab 持续存在)
→ showToast('已切换到 XiForge')
13.2 用户切换主题
点击 🎨 → ThemePopover 打开
→ 点击主题卡 → useThemeStore().setTheme('B')
→ store 内部: 写 localStorage + body.setAttribute('data-theme', 'B')
→ CSS 变量级联生效(无重渲染),所有组件颜色实时更新
→ showToast('已切换到主题 B · 极光青主导')
13.3 用户双击节点开 Tuning
FlowCanvas 节点 ondblclick
→ useTuningManager().open('GEQ#1', 'GEQ × 31', GEQTuningDialog, props, evt.target)
→ tunings Map 新增条目, x/y 计算自触发节点位置
→ TuningContainer (Teleport) v-for 渲染新 dialog
→ BottomPanel 的 tray-btn count 计数 +1
13.4 用户最小化 Tuning
点击黄色 traffic 或 ─ 按钮
→ emit minimize → useTuningManager().minimize('GEQ#1')
→ t.minimized = true
→ TuningContainer 中 v-show="!t.minimized" 隐藏
→ tray-btn count 不变(还在打开列表)
→ 在 tray 弹出层中点"显示" → useTuningManager().show('GEQ#1') 恢复
13.5 License 流程
用户登录后 → useLicenseStore().fetch() 拉取 tiers
→ MainTabs.vue computed 过滤 disabled tabs(无 license 的灰显加 🔒)
→ 用户点击未授权 tab → router beforeEach 拦截 → showToast 提示
→ VST 开发场景: 用户 tiers=['forge-pro'] → 仅 /xiforge 可访问
→ 启动后默认重定向到 /xiforge
→ 其他 3 个 tab 全部灰显
14. 验收标准(Definition of Done)
每个 § 章交付时必须满足:
- 能跑:
npm run dev启动无报错,浏览器打开默认重定向到/xistudio,4 主 tab 切换正常。 - macOS 风:检查 9 个关键点(§12 表格)全部实现,截图对比 demo 应"一眼看到 macOS 味"。
- 主题切换:6 套主题逐一切换不卡顿,刷新页面后从 localStorage 恢复。
- License 隔离:mock 一个
tiers=['forge-pro']场景,验证只能进入 /xiforge,其他 tab 灰显,路由守卫拦截。 - Tuning 多开:双击 3 个节点能同时弹出 3 个浮窗,可拖拽、最小化到托盘、托盘恢复、关闭。
- 快捷键:Ctrl+½/¾ / Ctrl+K T / Ctrl+Shift+P / F7 / F5 / Esc 全部可用。
- 现有组件:12 个 *TuningDialog 内容能渲染(即使数据是 mock),证明现有逻辑没破坏。
- 响应式:1280px 以下三栏自动收窄到 220/1fr/280。
- 可离线打开:v3-unified-macos.html 双击直接能演示(已通过)。
15. 不在本规格范围(P2 阶段)
- 实际后端联调(设备连接、引擎控制、license API):本规格只定 mock 数据形状
- 国际化 i18n
- 主题 D/E 在 XiStudio tab 的细节微调(节点底色、连线对比)
- macOS 真窗口控制(traffic-lights 实际关窗/最小化/最大化,需要 Electron)
- VST3 wrapper 的代码生成实际产出
附录 A · demo 与规格的对应索引
| demo 文件 | 规格章节 |
|---|---|
| L14–46 三色法典 | §1.2 tokens.css |
| L51–217 6 主题 | §1.3 themes.css |
| L228–262 macOS 全局 | §2 macos.css |
| L304–388 菜单栏 | §3.2 MenuBar.vue |
| L390–446 工具栏 | §3.3 ToolBar.vue |
| L450–500 主 tab | §4.3 MainTabs.vue |
| L502–532 子 tab | §4.4 SubTabs.vue |
| L535–662 三栏 | §5 WorkspaceShell + LeftSidebar + RightInspector |
| L668–784 底部 | §6 BottomPanel.vue |
| L800–906 Tuning 浮窗 | §7 TuningDialogShell + Container |
| L1531–L1647 Tuning JS | §7.2 useTuningManager.ts |
| L1648–L1701 命令面板 JS | §9 useCommandPalette.ts |
附录 B · 文件创建清单(前端智能体复制粘贴用)
# 新建目录
mkdir -p src/styles
mkdir -p src/composables
mkdir -p src/stores
mkdir -p src/components/shell
mkdir -p src/components/tuning
mkdir -p src/components/aux
mkdir -p src/components/global
mkdir -p src/views/xistudio/sub
mkdir -p src/views/xiforge/sub
mkdir -p src/views/xitune/sub
mkdir -p src/views/xiprobe/sub
mkdir -p src/router
# 新建文件 (按 §0 顺序)
touch src/styles/{tokens.css,themes.css,macos.css}
touch src/composables/{useTheme.ts,useTuningManager.ts,useAuxWindow.ts,useCommandPalette.ts}
touch src/stores/{theme.ts,license.ts,workspace.ts}
touch src/components/shell/{MenuBar.vue,ToolBar.vue,MainTabs.vue,SubTabs.vue,WorkspaceShell.vue,LeftSidebar.vue,RightInspector.vue,BottomPanel.vue,ScopeCard.vue}
touch src/components/tuning/{TuningDialogShell.vue,TuningContainer.vue}
touch src/components/aux/{AuxWindowShell.vue,XiCalWindow.vue,XiMicWindow.vue,XiBoxWindow.vue}
touch src/components/global/{ThemePopover.vue,CommandPalette.vue,ShortcutHandler.vue}
touch src/views/xistudio/XiStudioWorkspace.vue
touch src/views/xistudio/sub/{FlowView.vue,TuneView.vue,SimView.vue,DeployView.vue}
touch src/views/xiforge/XiForgeWorkspace.vue
touch src/views/xiforge/sub/{DesignView.vue,SourceView.vue,SimView.vue}
touch src/views/xitune/XiTuneWorkspace.vue
touch src/views/xitune/sub/{ChainView.vue,CurveView.vue,ABXView.vue}
touch src/views/xiprobe/XiProbeWorkspace.vue
touch src/views/xiprobe/sub/{FreqView.vue,THDView.vue,SNRView.vue,BatchView.vue}
touch src/router/index.ts
实现规格 v0.1 · 2026-05-14 · 基于 v3-unified-macos.html 同步生成