Foundation 是我维护的一个 Wails v3 桌面应用脚手架,Go 后端加 React 前端,目标是把窗口 主题 i18n 持久化 托盘这些基础设施一次做好,业务开发直接从页面写起。
到了 v0.0.3,有三个问题越来越明显:样式写在 MUI 的 sx 工厂函数里,每个 View 都要先 useTheme() 再算一遍样式;外壳写死成“标题栏 + 左侧图标栏 + 内容区”,想把导航挪个位置就得改 AppLayout;界面行为没有自动化测试,改完只能手工点一遍。
因此这次连发了两个大版本。v1.0.0 换掉 UI 底座,从 MUI 9 迁到 Mantine 9,同时跟进 Wails v3.1.0 正式版;v2.0.0 在新底座上把外壳重写成 CSS Grid,加上九种布局预设 悬浮 Dock 和 E2E 测试。两个版本同一天发布,本文合在一起讲。
仓库:FxRayHughes/foundation 从 v0.0.3 升级请先看 迁移指南,第 1–8 节对应 v1.0.0,第 9 节对应 v2.0.0。
版本全貌
首先我列一张表,把 v0.0.3 和 v2.0.0 的技术栈放在一起:
| 项 | v0.0.3 | v2.0.0 |
|---|---|---|
| Wails / Go | v3.0.0-beta.9 / 1.25 | v3.1.0 / 1.26 |
| UI 库 | @mui/material 9 + Emotion |
@mantine/core 9.7.5 |
| 样式写法 | <Name>.styles.ts + sx |
<Name>.module.css + var(--fd-*) |
| 图标 | @mui/icons-material |
@tabler/icons-react 3.50 |
| 图表 | @mui/x-charts |
@mantine/charts(recharts 3) |
| 表格 | @mui/x-data-grid |
mantine-datatable 9.4 |
| 应用外壳 | Flex,固定左侧图标栏 | CSS Grid,9 种布局预设 |
| 测试 | 无 E2E | Playwright + Wails server 模式 |
React 19 TypeScript 6 Vite 8 GORM 这些版本都没有动。v1.0.0 → v2.0.0 一共改了 123 个文件,新增约 3200 行代码和文档,另有 20 张自动生成的截图。
UI 底座:从 MUI 9 换到 Mantine 9
这一节的改动发生在 v1.0.0,v2.0.0 的布局系统全部建立在它之上。
依赖变化
简化后的 package.json diff 如下:
- @emotion/react ^11.14.0
- @emotion/styled ^11.14.1
- @mui/material ^9.0.1
- @mui/system ^7.3.11
- @mui/icons-material ^9.0.1
- @mui/x-charts ^9.3.0
- @mui/x-data-grid ^9.3.0
+ @mantine/core 9.7.5
+ @mantine/hooks 9.7.5
+ @mantine/charts 9.7.5
+ recharts 3.10.1
+ mantine-datatable 9.4.0
+ @tabler/icons-react 3.50.0
+ clsx ^2.1.1
Mantine 相关的包全部锁死精确版本,@wailsio/runtime 也锁在 3.1.0。Emotion 这层 CSS-in-JS 运行时随之移除,样式回到 CSS Modules。
样式:从 sx 工厂到 CSS Modules
v0.0.3 的样式层是一个工厂函数。每个组件有一份 Foo.styles.ts,接收 theme 返回一组 SxProps,View 里先 useTheme() 再调用它;选中态没法叠加,只能把 item 整份复制成 itemActive 再改颜色。
简化代码如下:
// v0.0.3:Sidebar.styles.ts
export const sidebarStyles = (theme: Theme) => {
const fp = theme.palette.foundation;
return {
item: { width: 48, height: 48, borderRadius: 1.5, color: fp.text.secondary,
'&:hover': { backgroundColor: fp.bg.hover } },
// 与 item 重复一遍,只改颜色
itemActive: { width: 48, height: 48, borderRadius: 1.5, color: fp.accent,
backgroundColor: fp.bg.active },
};
};
v1.0.0 改成 CSS Modules,状态交给 data 属性:
/* 双类选择器提高权重,覆盖 ActionIcon 的全局 subtle 样式 */
.item.item {
width: 48px;
height: 48px;
border-radius: 6px;
color: var(--fd-text-secondary);
}
.item.item:hover {
background-color: var(--fd-bg-hover);
}
.item.item[data-active] {
color: var(--fd-accent);
background-color: var(--fd-bg-active);
}
View 侧只剩 className={classes.item} data-active={isActive || undefined},不再需要 useTheme()。覆盖 Mantine 默认样式时用双类选择器或 classNames={{ root, input }},不用 !important。
迁移时最容易错的是单位换算。sx 里的数字会按主题自动换算,写进 CSS 必须换成显式 px:
sx 写法 |
换算规则 | 例子 |
|---|---|---|
p m gap 等间距 |
× 8px | py: 1.5 → padding-block: 12px |
borderRadius |
× 4px | borderRadius: 1.5 → 6px |
width height 等尺寸 |
直接加 px | width: 64 → 64px |
'&:hover' '&.Mui-selected' |
独立 CSS 规则 | 选中态改用 [data-active] |
v0.0.3 的 Sidebar.styles.ts 里有一行 borderRadius: 1.5, // 12px,注释是错的,实际渲染是 1.5 × 4 = 6px。❗ 迁移时按规则换算,不要照抄注释。
主题:调色板编译成 CSS 变量
Foundation 的主题预设是一份 FoundationPalette,16 个语义槽位:背景 7 个 文字 3 个 分隔线 强调色 强调色悬停 以及 3 个状态色。这份结构在迁移中一个字段都没改,变的是它怎么交给 UI 库。
这张图回答一个问题:同一份调色板,怎么同时驱动自己写的样式和 Mantine 的原生组件。

buildMantineTheme 做了三件事:
- 调色板写成
:root上的--fd-*变量,组件样式只引用变量。 accent分别与白 黑混色,生成 10 级色阶foundation并设为primaryColor;shade 6 是 accent,shade 7 是 accentHover,正好对上 Mantine filled 按钮取primaryShade + 1作为悬停色的规则。- Mantine 自己的语义变量全部指向
--fd-*,比如--mantine-color-body→--fd-bg-content,没写任何样式的 Mantine 组件也跟随预设。
这一步的关键是:切换主题只是替换变量值,组件不需要重新渲染。v0.0.3 的 sx 是渲染时按 theme 计算的,主题一变,所有调用了 useTheme() 的组件都要重算。
业务代码取色按优先级分三种:
- CSS 里写
var(--fd-xxx),覆盖绝大多数场景; - JS 只需要引用颜色时(图表的
color内联style),用FOUNDATION_CSS_VARS.xxx,值就是字符串'var(--fd-xxx)'; - 必须拿到真实色值时(canvas 颜色计算),用
useFoundationPalette()。
全局默认值也收在这一层:ActionIcon 默认 variant: 'subtle',Tooltip 默认带箭头且延迟 300ms,圆角档位是 xs 2px / sm 4px / md 8px / lg 12px,按钮用 4–6px,容器用 8px。
组件 图标与图表
v0.0.3 实际用到的 MUI 组件不多,常用的映射关系如下:
| MUI | Mantine | 要注意的差异 |
|---|---|---|
Button |
Button |
contained/outlined/text → filled/outline/subtle,startIcon → leftSection |
IconButton |
ActionIcon |
必须带 aria-label={t(...)} |
Typography |
Text / Title |
颜色属性是 c,项目里一般直接用 class |
TextField |
TextInput |
helperText → description |
Tooltip title= |
Tooltip label= |
placement → position |
ButtonBase |
UnstyledButton |
设置页的卡片和列表项 |
图标统一换成 Tabler,写法固定为 <IconXxx size={20} stroke={1.75} />,尺寸只用 14 16 18 20 24 几档。路由表的图标类型也从 ComponentType<{ fontSize }> 改成了 Tabler 的 Icon。
图表换成 @mantine/charts,数据结构从“series[].data + 坐标轴配置”变成“行数组 + dataKey + series”。这里有一个方向陷阱:MUI 的 layout="horizontal"(类目在 Y 轴的横向条形图)对应 Mantine 的 orientation="vertical",两边的命名正好相反。
X-Pro 组件重写
上一个版本为了不买 MUI X 的 Pro / Premium 授权,自己实现了一套 DataGrid 增强和 8 种图表。底座一换,这一层也要跟着重写。
DataGridPro / DataGridPremium 改为基于 mantine-datatable,不再继承 MUI 的 DataGridProps,变成带泛型的独立 props。列定义随之改名:
// v0.0.3
const columns: GridColDef[] = [
{ field: 'salary', headerName: t('xpro.col.salary'), type: 'number', width: 120 },
];
// v1.0.0:field → accessor,renderCell → render(row, index),valueGetter 只接收行对象
const columns: DataGridColumn<DemoRow>[] = [
{ accessor: 'salary', headerName: t('xpro.col.salary'), type: 'number', width: 120 },
];
表头新增了列菜单(排序 钉住 隐藏 管理列)和列宽拖拽。mantine-datatable 自带的列宽调整依赖 storeColumnsKey,底层写的是 localStorage,而 Foundation 规定跨会话状态只能走 SQLite,因此列宽由 useColumnResizing 自己管理:指针捕获加 rAF 节流,松手后吞掉浏览器补发的那次 click 避免误触发排序,双击手柄恢复默认宽度。需要记住列宽时,在 onColumnWidthChange 回调里写进 PreferencesService 即可。
这是 X-Pro 演示页里重写后的表格:

导出也换了路径。v0.0.3 的 Excel 导出会动态 import('xlsx'),但这个包根本不在依赖里,功能实际不可用;v1.0.0 改成原生“另存为”对话框选路径,再由 Go 侧 exportsvc 写文件。CSV 带 UTF-8 BOM,Excel 直接双击打开中文不乱码;XLSX 用 excelize 生成,表头加粗 冻结首行 数字保持数值类型,落盘统一走 filex.WriteAtomic。
8 种图表的组件名不变,大多数改为封装 @mantine/charts。横向漏斗和甘特图例外:Recharts 的 Funnel 不支持横向,@mantine/charts 也没有甘特图,这两种用 SVG 自绘;横向漏斗的 Tooltip 仍复用 ChartTooltip,和纵向保持一致。
跨窗口主题同步
v0.0.3 的子窗口入口固定使用亮色预设,主窗口切到暗色后,弹出的确认框还是白的。v1.0.0 给 appsettings 的写入加了一次广播:写库成功后发出 appsettings:changed,payload 是变更的字段名。
主窗口 子窗口 托盘面板都订阅这个事件。FoundationThemeProvider 收到 themeChoice 或 customTheme 后防抖 200ms 再重新拉取,I18nProvider 收到 localeChoice 后同理;三个窗口不共享 React 树,状态只经过后端这一个来源。
外壳:从固定 Flex 到 CSS Grid
问题
v1.0.0 的 AppLayout 还是一个固定结构:
<>
<TitleBar />
<div className={classes.root}> {/* display: flex; flex-direction: row */}
<Sidebar />
<main className={classes.main}><RouterOutlet /></main>
</div>
</>
想把导航放到右边 顶部,或者嵌进标题栏,只能在外壳里加分支;每加一种形态,AppLayout 就多一层 if。
布局预设:三个维度
v2.0.0 把“外壳长什么样”抽成一份预设数据,只声明三件事:
interface LayoutPreset {
id: string;
labelKey: string; // 设置页卡片标题
descriptionKey: string; // 设置页卡片说明
nav: { variant: NavVariant; placement: NavPlacement };
frame: LayoutFrame;
}
type NavVariant = 'rail' | 'sidebar' | 'bar' | 'dock' | 'none';
type NavPlacement = 'left' | 'right' | 'top' | 'titlebar' | 'bottom' | 'float' | 'drawer';
type LayoutFrame = 'flush' | 'inset';
variant 是导航组件的形态,placement 是导航放在哪,frame 是内容区贴边还是做成浮动卡片。
这张矩阵回答:九种内置布局分别落在哪个组合上,哪些组合是空的。

内置预设在 presets.ts 里各占一行:
export const BUILTIN_LAYOUTS: LayoutPreset[] = [
p('classic', 'rail', 'left'), // 经典:左侧图标栏(默认)
p('sidebar', 'sidebar', 'left'), // 展开侧栏:图标 + 文字,可折叠
p('topnav', 'bar', 'top'), // 顶部导航
p('titlebar', 'bar', 'titlebar'), // 融合标题栏
p('right', 'rail', 'right'), // 右侧栏
p('dock', 'dock', 'bottom'), // 底部 Dock
p('floating', 'dock', 'float'), // 悬浮 Dock
p('inset', 'sidebar', 'left', 'inset'), // 浮动卡片
p('focus', 'none', 'drawer'), // 专注:菜单按钮呼出抽屉
];
业务方不想改脚手架文件的话,在 App.tsx 里调用 layoutRegistry.register(preset) 即可,设置页的卡片和缩略图会自动出现。读取时走 layoutRegistry.resolve(id),旧版本存下的值或者被卸载的自定义预设都会回落到 classic。
网格区域
AppLayout 本身只做三件事:把 data-layout data-placement data-frame 写到根节点,决定导航放进网格还是标题栏插槽,再渲染 <RouterOutlet />。真正的排版全部在 CSS 里按属性切换。
这张图对照六类 placement 各自生成的网格区域:

以左右两种为例:
.root {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: 'title' 'main';
}
.root[data-placement='left'] {
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas: 'title title' 'nav main';
}
.root[data-placement='right'] {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: 'title title' 'main nav';
}
列和行一律写 minmax(0, 1fr),而不是 1fr。1fr 的下限是内容的最小宽度,长表格或者不换行的代码块会把网格撑破;minmax(0, 1fr) 允许它收缩,溢出交给内容区自己滚动。
bottom 和 float 两种比较特殊:导航不单独占一格,而是和 main 共用同一个网格单元,浮在内容之上。底部 Dock 用 align-self: end 贴底居中,内容区底部让出 80px;悬浮 Dock 的那一层铺满内容区,自身 pointer-events: none,只有 Dock 本体能接收点击。
inset 外框则把根背景换成侧栏色,给 main 加 8px 外边距 1px 边框和 8px 圆角,贴着导航的那一侧外边距归零。
九种布局的实际效果如下,截图都由 E2E 脚本生成:
| 经典(默认) | 展开侧栏 | 顶部导航 |
|---|---|---|
![]() |
![]() |
![]() |
| 融合标题栏 | 右侧栏 | 底部 Dock |
![]() |
![]() |
![]() |
| 悬浮 Dock | 浮动卡片 | 专注(抽屉展开) |
![]() |
![]() |
![]() |
导航组件与标题栏插槽
原来的 Sidebar 拆成了 components/Navigation 下的五个组件:
NavRail:64px 图标栏,左右两用,激活指示条 分隔线 Tooltip 方向跟着 placement 翻转;NavSidebar:图标 + 文字,可折叠为图标栏,折叠状态存在偏好navCollapsed里;NavBar:水平导航条,放在标题栏下方时 44px,带品牌和底部分隔线,嵌进标题栏时不超过 28px;NavDock:底部横向胶囊,或者竖向悬浮加拖拽手柄;NavigationDrawerButton:专注布局的菜单按钮,点开是 MantineDrawer加抽屉版侧栏。
它们共用一个 ViewModel useNavigation(),条目直接来自路由表的 primary 和 footer 两段,不接收 items 或 onNavigate 这类 props。Navigation.tsx 只是一个分发器,按 variant 选组件,把 placement 透传下去。旧的 @/components/Sidebar 保留为 NavRail 的别名,存量代码照样能 import。
标题栏为此多了两个插槽:leading 放在标题前(专注布局的菜单按钮),center 占据标题与窗口按钮之间的剩余宽度(融合标题栏的导航)。整条标题栏是窗口拖拽区,插槽外层会自动声明 --wails-draggable: no-drag,按钮可以点击,插槽之外的空白仍然能拖动窗口。
设置页与持久化
布局在 设置 -> 布局 里切换,每个预设一张卡片,缩略图用纯 CSS 按 variant placement frame 摆出来,整组是一个可以用键盘操作的 radiogroup。
这是布局设置页的样子:

选择写进 SQLite 的 preferences 表,新增四个键:
| 键 | 默认值 | 含义 |
|---|---|---|
layout |
'classic' |
当前预设 id |
navCollapsed |
false |
展开侧栏是否折叠 |
floatDockSide |
'right' |
悬浮 Dock 吸附的边 |
floatDockY |
0.5 |
悬浮 Dock 中心占内容区高度的比例 |
PreferencesProvider 读回数据时会和默认值合并,未知键和类型不一致的值直接丢弃。旧数据库里没有这些键,读出来就是默认值,所以升级不需要写迁移脚本。
路由也顺带支持了参数:navigate('settings', { section: 'layout' }) 可以直达设置子页,首页新增的“当前布局”卡片就是这么跳过去的。
悬浮 Dock:拖拽的边界
悬浮 Dock 是九种布局里交互最复杂的一种:它竖向浮在内容区一侧,拖动顶部手柄可以移到任意高度,松手吸附到最近的左边或右边。
为什么不能进标题栏
在 macOS 上,窗口顶部 40pt 是原生拖拽区,左上角还有红绿灯。Dock 一旦被拖进这块区域,手柄上的指针事件会被系统的窗口拖拽吞掉,之后就再也拖不动了。
因此第一条约束是:Dock 必须完整地待在内容区里。这张图标出了它能停留的区间,以及三个阶段各自的夹取方式:

拖拽中:按中心点夹取
按下手柄时,先记录指针相对 Dock 中心的偏移和内容区的矩形;移动时把中心点夹在合法区间里,x 是 [halfW, 宽 - halfW],y 在此基础上上下各多留 12px(SAFE_GAP)。超出范围的位置直接截断到边界,Dock 不会跟着指针越界。
指针一旦移出手柄,pointermove 就收不到了,所以手柄调用 setPointerCapture 把后续事件锁在自己身上,同时在 window 上也挂一份监听作为兜底。
结束拖拽的条件不只是 pointerup:
// 正常松手 / 取消 / 失去捕获 / 窗口失焦 —— 一律以最后一个合法位置提交
handle.addEventListener('pointerup', finish);
window.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
handle.addEventListener('lostpointercapture', finish);
window.addEventListener('blur', finish);
blur 这一条是专门留的。如果用户在 macOS 的原生拖拽区松手,webview 收不到 pointerup,只会失焦;不监听它,Dock 就会卡在“拖拽中”的状态。
静止时:用 CSS clamp() 定位
松手后存的不是像素,而是比例:floatDockSide 按中心点落在左半还是右半决定,floatDockY 是中心点 y 除以内容区高度。窗口缩放之后,比例依然成立。
渲染时再把比例换回位置,夹取交给 CSS:
top: `clamp(${safeGap}px, calc(${y * 100}% - var(--dock-half-h)), calc(100% - 2 * var(--dock-half-h) - ${safeGap}px))`
--dock-half-h 是 Dock 实测高度的一半,由 ResizeObserver 写入。路由条目增减时 Dock 变高变矮,变量跟着更新,不需要在 JS 里监听窗口尺寸。
键盘与遮挡
手柄是一个 <button>,键盘也能操作:上下方向键每次移动 5%,左右方向键换边,双击手柄复位到右侧居中。拖拽期间 Tooltip 会被禁用,避免跟着指针闪烁。
Dock 常驻在内容区一侧,所以外壳会根据 data-dock-side 给 main 在吸附侧加 88px 内边距(Dock 宽 56px + 边距 16px + 留白),页面内容不会被它挡住。
启动防跳变
现象
偏好存在 SQLite 里,前端要等 Go 侧读回来才知道用户选了哪种布局。v1.0.0 的启动骨架在 React 首帧之后就淡出了,这时偏好往往还没回来:界面先按默认的经典布局渲染,随后再跳成用户选的悬浮 Dock。
方案
这张图对比了两种时序:

v2.0.0 让骨架一直盖到偏好就位:
index.html内联的静态骨架是一个position: fixed的覆盖层,React 在它下面渲染;PreferencesProvider读回偏好后把ready置为 true,读失败也置 true,表示“不会再变了”;useAppLayout看到ready后派发foundation:app-ready事件;main.tsx收到事件,等两帧requestAnimationFrame确认 React 已经提交并绘制,再淡出骨架。
window.addEventListener(APP_READY_EVENT, scheduleSkeletonRemoval, { once: true });
// 兜底:后端异常时也不会一直挡住
window.setTimeout(scheduleSkeletonRemoval, 1500);
用户看到的第一帧真实界面就是自己选的布局,中间没有跳变。
macOS 红绿灯对齐
现象
Foundation 在 macOS 上保留系统红绿灯,隐藏原生标题栏背景,由前端在红绿灯右侧画标题。v1.0.0 的标题栏是 36px,标题文字的中线在 18px,红绿灯却明显偏下,而且进出全屏 换系统版本时位置还会变。
原因
红绿灯的位置由 NSToolbar 的高度决定,Wails 没有直接设置坐标的 API。MacTitleBarHiddenInset 预设的 ToolbarStyle 是 Automatic,系统按窗口配置推断样式,多数情况落到 52pt 的 Unified 工具栏,红绿灯中心约 26pt。
方案
既然不能设坐标,就把工具栏样式固定下来,再让前端去对齐它。这张图把前后两种情况画在同一把刻度尺上:

Go 侧显式指定紧凑工具栏:
func macTitleBar() application.MacTitleBar {
tb := application.MacTitleBarHiddenInset
// 紧凑工具栏:红绿灯中心稳定在约 19.5pt
tb.ToolbarStyle = application.MacToolbarStyleUnifiedCompact
return tb
}
前端在 macOS 上把标题栏改成 40px,中线 20px,与红绿灯对齐;InvisibleTitleBarHeight 也设成 40,整条标题栏都能拖动窗口。高度只通过 CSS 变量 --fd-titlebar-h 控制,index.html 里有一段内联脚本在首帧前写入 data-platform="darwin",启动骨架和 React 标题栏用的是同一个高度。
❗ 这个对齐涉及三处:window_darwin.go 的 ToolbarStyle 和 InvisibleTitleBarHeight,global.css 的 --fd-titlebar-h,index.html 的骨架样式。改任意一处都要同步另外两处。
效果
实测红绿灯与标题文字 标题栏中线的偏差不超过 1pt,这是 macOS 上的原生截图:

E2E 测试与自动截图
现象
v2.0.0 要在 README 里放九种布局加三套主题的截图。手工截图既费时,界面一改又会整批过期;悬浮 Dock 这种拖拽交互,靠手工点一遍也很难覆盖到边界情况。
方案
Wails 支持 server 模式:用 -tags server 构建出的二进制不开原生窗口,直接以 HTTP 提供同一套前端,加上全部 Go service 和事件。Playwright 驱动浏览器访问它,测到的就是真实后端,而不是 mock。
这张图展示从构建到测试的整条链路:

数据隔离靠一个新的环境变量 FOUNDATION_DATA_DIR。设置后,数据库 主密钥 日志三者的根目录都改到这个路径,不再使用系统用户目录:
func userDataDir() (string, error) {
// 便携版 / E2E 测试 / server 模式隔离数据
if v := os.Getenv(DataDirEnv); v != "" {
return v, nil
}
// 否则按平台规范:%AppData% / Application Support / XDG
// ...
}
playwright.config.ts 的 webServer 每次先清空 .e2e-data/,再带着 FOUNDATION_DATA_DIR 启动后端,所以测试永远从一个空库开始,也碰不到开发者自己的真实数据。测试共用一个后端和一个数据库,workers 固定为 1 串行执行;浏览器默认用本机的 Edge,免去下载。
两类用例
floating-dock.spec.ts 是悬浮 Dock 的回归测试,按用户操作的路径走:
- 进入 设置 -> 布局,选择悬浮 Dock;
- 把手柄拖到窗口左上角(红绿灯的位置),断言 Dock 顶边在标题栏下方,并且吸附到了左侧;
- 再拖到右下角,断言手柄仍然可拖 吸附到右侧 没有越过底边;
- 刷新页面,断言位置和刷新前相差不到 4px。
screenshots.spec.ts 负责生成 README 截图。它不点界面,而是直接用 sqlite3 把布局和主题写进 E2E 数据库再刷新页面,保证每张图可复现;同时把 navigator.platform 伪装成 Win32,让标题栏按 Windows 样式渲染。上面九张布局截图都是这样生成的。
wails3 task build:server # 产物 bin/foundation-server
cd frontend && pnpm e2e # 全部用例
pnpm e2e:screenshots # 只重新生成 docs/screenshots/
浏览器里没有原生窗口,托盘图标 红绿灯 原生对话框这三样仍然要在桌面构建里手动验证。FOUNDATION_DATA_DIR 同样可以用来做便携版,把数据目录指到程序旁边即可。
升级要点
从 v1.0.0 升级到 v2.0.0,破坏性变更只有三条:
AppLayout重写为 CSS Grid,fork 里改过外壳的,需要对照新的useAppLayout()合并,它现在还负责发出启动就绪信号;components/Sidebar的实现迁到了components/Navigation/NavRail,Sidebar仍可 import,但Sidebar.module.css已删除;- macOS 标题栏从 36px 改为 40px,写死过高度的地方要改成
var(--fd-titlebar-h)。
从 v0.0.3 直接升级,要先按迁移指南第 1–8 节完成 MUI 到 Mantine 的迁移,再看第 9 节。AI 协作文档也跟着换了:11 份 foundation-mui* skill 合并为 5 份 foundation-mantine*,v2.0.0 又新增了 foundation-layout,记录上面这套预设和约束。
把会变的东西抽成数据和变量,把容易坏的地方交给真实后端的测试去守,脚手架才扛得住下一次大改。










参与讨论
(Participate in the discussion)
参与讨论