mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-28 19:19:34 +08:00
✨ feat(mac-window): 支持切换 macOS 原生窗口控制与原生全屏行为 (#288)
## 背景 当前 GoNavi 使用自定义无边框标题栏与右上角窗口按钮,在 macOS 下与系统原生窗口交互习惯存在明显差异: - 缺少左上角原生红黄绿窗口控制按钮 - 绿色按钮不具备 macOS 原生全屏 / Space 语义 - 标题栏交互和系统应用不够一致 这个 PR 为 macOS 增加了可切换的原生窗口控制模式,在尽量不影响现有跨平台行为的前提下,补齐 macOS 用户更熟悉的窗口体验。 ## 变更内容 - 在 `主题 -> 外观参数` 中新增 `使用 macOS 原生窗口控制` 开关 - 启用后: - 显示 macOS 左上角原生红黄绿按钮 - 隐藏现有右上角自定义窗口按钮 - 为标题栏内容预留原生按钮安全区域 - 优先使用 macOS 原生全屏行为 - 支持 `Control + Command + F` 切换全屏 - 修复原生全屏下按 `Esc` 会意外退出全屏的问题 - 补充窗口行为、边界条件和相关工具函数单元测试 ## 影响范围 - 仅影响 macOS 下启用该开关时的窗口样式与交互 - Windows/Linux 默认行为不变 - Windows 构建已验证通过 ## 验证结果 已完成以下验证: - [x] `npm run test` - [x] `npm run build` - [x] `go test ./...` - [x] macOS 手工验证通过 - [x] Windows 构建验证通过 ### macOS 手工验证项 - [x] 设置页可见 `使用 macOS 原生窗口控制` - [x] 开关关闭时,保留当前自定义标题栏与右上角按钮 - [x] 开关开启时,右上角自定义按钮隐藏 - [x] 开启后显示左上角原生红黄绿按钮 - [x] 绿色按钮进入原生全屏 - [x] 原生全屏进入独立 Space - [x] `Control + Command + F` 可切换全屏 - [x] 原生全屏下按 `Esc` 不再意外退出全屏 - [x] 浅色 / 深色主题下显示正常 - [x] 模糊与透明效果在普通窗口和全屏下均可正常工作 - [x] 最小化行为正常 ## 截图 / 演示 ### 历史窗口样式 - `MAC_历史版本窗口.png` <img width="1920" height="1080" alt="MAC_历史版本窗口" src="https://github.com/user-attachments/assets/4bd9176f-9d7e-43d1-9e1a-c7a6bfc0e28c" /> ### 设置项与菜单 - `MAC_菜单控制.png` <img width="1278" height="909" alt="MAC_菜单控制" src="https://github.com/user-attachments/assets/520da1b5-af59-4f1a-ba5d-36abdc03ef60" /> - `MAC_菜单控制_Dark.png` <img width="1119" height="861" alt="MAC_菜单控制_Dark" src="https://github.com/user-attachments/assets/b21af50e-b583-4895-b316-cc21b7498a51" /> - `MAC_恢复默认.png` <img width="1526" height="922" alt="MAC_恢复默认" src="https://github.com/user-attachments/assets/0129f69d-b2ca-45eb-847a-6b6cb37ca576" /> ### 原生窗口控制效果 - `MAC_窗口组件原生控制.png` <img width="1236" height="849" alt="MAC_窗口组件原生控制" src="https://github.com/user-attachments/assets/003dba09-d0a8-4999-8241-f2d1db078d1b" /> - `MAC_窗口组件原生控制2.png` <img width="1920" height="834" alt="MAC_窗口组件原生控制2" src="https://github.com/user-attachments/assets/241c94a6-955f-41f8-9b1d-b9a40d0a5251" /> - `MAC_切换后变化.png` <img width="1920" height="1080" alt="MAC_切换后变化" src="https://github.com/user-attachments/assets/52d8977b-9c64-4413-85d9-f94bdcdc0e53" /> ### 全屏、快捷键与 Space 行为 - `MAC_快捷键.png` <img width="1227" height="846" alt="MAC_快捷键" src="https://github.com/user-attachments/assets/2972cee3-3621-42f1-bd05-1e24eaded5ef" /> - `MAC_支持SPACE切换.png` <img width="353" height="251" alt="MAC_支持SPACE切换" src="https://github.com/user-attachments/assets/044974a6-64c4-4d0c-8ba9-3445af77f8e4" /> - `MAC_最大化.png` <img width="1920" height="1079" alt="MAC_最大化" src="https://github.com/user-attachments/assets/dbdf4cd4-0abd-4142-9c81-08c8c23af73b" /> ### 模糊与透明效果 - `MAC_模糊与透明.png` <img width="1267" height="954" alt="MAC_模糊与透明" src="https://github.com/user-attachments/assets/f5a3a377-805e-4d5f-a3f0-fa588d77d9f7" /> - `MAC_模糊与透明_全屏.png` <img width="1920" height="1080" alt="MAC_模糊与透明_全屏" src="https://github.com/user-attachments/assets/e20642ba-b828-47d0-a154-c23a7b15643d" /> ### 其他窗口行为 - `MAC_窗口最小化.png` <img width="276" height="129" alt="MAC_窗口最小化" src="https://github.com/user-attachments/assets/d7f758a0-072e-4c47-95e6-9539075f1d71" /> - `MAC_设置启动全屏-重新打开.png` <img width="1920" height="1080" alt="MAC_设置启动全屏-重新打开" src="https://github.com/user-attachments/assets/b033d102-5062-46cb-9c41-c6fe330df007" /> ### Windows 回归验证 - `WINDOWS_菜单.png` <img width="1920" height="1040" alt="WINDOWS_菜单" src="https://github.com/user-attachments/assets/3a295470-c1c6-42f5-a265-2cd38e9224cf" /> - `WINDOWS_全屏.png` <img width="1920" height="1040" alt="WINDOWS_全屏" src="https://github.com/user-attachments/assets/b254dc81-0c42-4024-9f91-3e029bfe29a0" /> ## 说明 - 本次实现优先保证 macOS 原生窗口交互一致性,而不是模拟系统按钮视觉 - 当前方案对非 macOS 平台保持兼容 - 如果窗口样式在切换当次未完全刷新,重启应用后可获得稳定表现
This commit is contained in:
@@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SHORTCUT_ACTION_META,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
} from './utils/shortcuts';
|
||||
import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -722,6 +723,19 @@ function App() {
|
||||
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
|
||||
const isWindowsRuntime = runtimePlatform === 'windows'
|
||||
|| (runtimePlatform === '' && isWindowsPlatform());
|
||||
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreHydrated || !isMacRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void SetMacNativeWindowControls(useNativeMacWindowControls).catch(() => undefined);
|
||||
} catch (e) {
|
||||
console.warn('Wails API: SetMacNativeWindowControls unavailable', e);
|
||||
}
|
||||
}, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]);
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
@@ -1169,6 +1183,10 @@ function App() {
|
||||
await WindowUnfullscreen();
|
||||
return;
|
||||
}
|
||||
if (useNativeMacWindowControls && isMacRuntime) {
|
||||
await WindowFullscreen();
|
||||
return;
|
||||
}
|
||||
await WindowToggleMaximise();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
@@ -1180,6 +1198,9 @@ function App() {
|
||||
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
|
||||
return;
|
||||
}
|
||||
if (useNativeMacWindowControls) {
|
||||
return;
|
||||
}
|
||||
void handleTitleBarWindowToggle();
|
||||
};
|
||||
|
||||
@@ -1330,8 +1351,34 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMacNativeEscapeCapture = (event: KeyboardEvent) => {
|
||||
if (!shouldSuppressMacNativeEscapeExit(isMacRuntime, useNativeMacWindowControls, useStore.getState().windowState === 'fullscreen', event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleMacNativeEscapeCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleMacNativeEscapeCapture, true);
|
||||
};
|
||||
}, [isMacRuntime, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
if (shouldHandleMacNativeFullscreenShortcut(isMacRuntime, useNativeMacWindowControls, event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleTitleBarWindowToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
|
||||
const binding = shortcutOptions[action];
|
||||
if (!binding?.enabled) {
|
||||
@@ -1376,7 +1423,7 @@ function App() {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcut);
|
||||
};
|
||||
}, [handleNewQuery, shortcutOptions, themeMode, setTheme]);
|
||||
}, [handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingShortcutAction) {
|
||||
@@ -1523,40 +1570,45 @@ function App() {
|
||||
userSelect: 'none',
|
||||
WebkitAppRegion: 'drag', // Wails drag region
|
||||
'--wails-draggable': 'drag',
|
||||
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
|
||||
paddingLeft: getMacNativeTitlebarPaddingLeft(effectiveUiScale, useNativeMacWindowControls),
|
||||
paddingRight: getMacNativeTitlebarPaddingRight(effectiveUiScale, useNativeMacWindowControls),
|
||||
fontSize: tokenFontSize
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600, minWidth: 0 }}>
|
||||
{/* Logo can be added here if available */}
|
||||
GoNavi
|
||||
</div>
|
||||
<div
|
||||
data-no-titlebar-toggle="true"
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MinusOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={WindowMinimise}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => { void handleTitleBarWindowToggle(); }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={Quit}
|
||||
/>
|
||||
</div>
|
||||
{useNativeMacWindowControls ? (
|
||||
<div style={{ minWidth: Math.max(40, Math.round(48 * effectiveUiScale)) }} />
|
||||
) : (
|
||||
<div
|
||||
data-no-titlebar-toggle="true"
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MinusOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={WindowMinimise}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => { void handleTitleBarWindowToggle(); }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={Quit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
|
||||
@@ -2008,6 +2060,24 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMacRuntime ? (
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>macOS 窗口控制</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>使用 macOS 原生窗口控制</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>启用后显示左上角红黄绿按钮,并优先使用 macOS 原生全屏行为。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appearance.useNativeMacWindowControls === true}
|
||||
onChange={(checked) => setAppearance({ useNativeMacWindowControls: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 8 }}>
|
||||
* 已同步隐藏右上角自定义按钮;如系统窗口样式未立即刷新,可重启应用后再确认
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
@@ -2020,13 +2090,13 @@ function App() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0 });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
describe('dataGridLayout helpers', () => {
|
||||
it('returns zero bottom padding without horizontal overflow', () => {
|
||||
expect(calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: false,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: false,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
}),
|
||||
0,
|
||||
'无横向滚动条时不应增加底部间距'
|
||||
);
|
||||
it('adds safe area when horizontal overflow exists', () => {
|
||||
expect(calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
})).toBe(28);
|
||||
expect(calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 14,
|
||||
floatingScrollbarGap: 4,
|
||||
})).toBe(30);
|
||||
});
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 10,
|
||||
floatingScrollbarGap: 6,
|
||||
}),
|
||||
28,
|
||||
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateTableBodyBottomPadding({
|
||||
hasHorizontalOverflow: true,
|
||||
floatingScrollbarHeight: 14,
|
||||
floatingScrollbarGap: 4,
|
||||
}),
|
||||
30,
|
||||
'较粗滚动条场景下应同步放大底部安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 1200,
|
||||
isMacLike: false,
|
||||
}),
|
||||
1200,
|
||||
'列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 0,
|
||||
isMacLike: false,
|
||||
}),
|
||||
646,
|
||||
'未拿到视口宽度时应退回列宽总和'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 1200,
|
||||
tableViewportWidth: 800,
|
||||
isMacLike: true,
|
||||
}),
|
||||
1202,
|
||||
'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
|
||||
);
|
||||
|
||||
console.log('dataGridLayout tests passed');
|
||||
it('keeps scroll width aligned with viewport or content width', () => {
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RedisKeyInfo } from '../types';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
@@ -7,20 +9,6 @@ import {
|
||||
isGroupFullyChecked,
|
||||
} from './redisViewerTree';
|
||||
|
||||
const assert = (condition: unknown, message: string) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
const actualText = JSON.stringify(actual);
|
||||
const expectedText = JSON.stringify(expected);
|
||||
if (actualText !== expectedText) {
|
||||
throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sampleKeys: RedisKeyInfo[] = [
|
||||
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
||||
{ key: 'app:user:2', type: 'string', ttl: -1 },
|
||||
@@ -28,78 +16,64 @@ const sampleKeys: RedisKeyInfo[] = [
|
||||
{ key: 'misc', type: 'set', ttl: -1 },
|
||||
];
|
||||
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
|
||||
describe('redisViewerTree helpers', () => {
|
||||
it('builds grouped redis key tree and group selection state', () => {
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
|
||||
|
||||
assert(appGroup, '应生成 group:app 节点');
|
||||
assert(userGroup, '应生成 group:app:user 节点');
|
||||
assertEqual(
|
||||
appGroup?.descendantRawKeys,
|
||||
['app:order:1', 'app:user:1', 'app:user:2'],
|
||||
'app 分组应收集全部后代 key'
|
||||
);
|
||||
expect(appGroup).toBeTruthy();
|
||||
expect(userGroup).toBeTruthy();
|
||||
expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
|
||||
|
||||
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
|
||||
assertEqual(
|
||||
selectedAfterGroupCheck,
|
||||
['app:order:1', 'app:user:1', 'app:user:2'],
|
||||
'勾选分组应递归选中全部后代 key'
|
||||
);
|
||||
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
|
||||
expect(selectedAfterGroupCheck).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
|
||||
|
||||
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
|
||||
assertEqual(
|
||||
checkedState.checked,
|
||||
['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'],
|
||||
'全部后代已选中时,父分组和叶子都应进入 checked'
|
||||
);
|
||||
assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked');
|
||||
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked');
|
||||
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
|
||||
expect(checkedState.checked).toEqual(['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app']);
|
||||
expect(checkedState.halfChecked).toEqual([]);
|
||||
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck)).toBe(true);
|
||||
|
||||
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
|
||||
assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key');
|
||||
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked');
|
||||
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
|
||||
expect(selectedAfterGroupUncheck).toEqual([]);
|
||||
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck)).toBe(false);
|
||||
});
|
||||
|
||||
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
|
||||
assertEqual(
|
||||
partialState.halfChecked,
|
||||
['group:app:user', 'group:app'],
|
||||
'仅部分后代选中时,相关分组应进入 halfChecked'
|
||||
);
|
||||
assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked');
|
||||
it('marks parent groups as half checked for partial selection', () => {
|
||||
const tree = buildRedisKeyTree(sampleKeys, true);
|
||||
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
|
||||
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
|
||||
|
||||
const renamedState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'app:user:2',
|
||||
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
|
||||
},
|
||||
'app:user:2',
|
||||
'app:user:200'
|
||||
);
|
||||
expect(partialState.halfChecked).toEqual(['group:app:user', 'group:app']);
|
||||
expect(isGroupFullyChecked(appGroup!, ['app:user:1'])).toBe(false);
|
||||
});
|
||||
|
||||
assertEqual(
|
||||
renamedState.keys.map((item) => item.key),
|
||||
['app:user:1', 'app:user:200', 'app:order:1', 'misc'],
|
||||
'重命名后 keys 列表应替换旧 key'
|
||||
);
|
||||
assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key');
|
||||
assertEqual(
|
||||
renamedState.selectedKeys,
|
||||
['app:user:1', 'app:user:200', 'misc'],
|
||||
'批量选中集合中的旧 key 应映射为新 key'
|
||||
);
|
||||
it('updates selected keys consistently after rename', () => {
|
||||
const renamedState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'app:user:2',
|
||||
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
|
||||
},
|
||||
'app:user:2',
|
||||
'app:user:200'
|
||||
);
|
||||
|
||||
const unrelatedRenameState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'misc',
|
||||
selectedKeys: ['app:user:1'],
|
||||
},
|
||||
'app:order:1',
|
||||
'app:order:9'
|
||||
);
|
||||
assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey');
|
||||
assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合');
|
||||
expect(renamedState.keys.map((item) => item.key)).toEqual(['app:user:1', 'app:user:200', 'app:order:1', 'misc']);
|
||||
expect(renamedState.selectedKey).toBe('app:user:200');
|
||||
expect(renamedState.selectedKeys).toEqual(['app:user:1', 'app:user:200', 'misc']);
|
||||
|
||||
console.log('redisViewerTree tests passed');
|
||||
const unrelatedRenameState = applyRenamedRedisKeyState(
|
||||
{
|
||||
keys: sampleKeys,
|
||||
selectedKey: 'misc',
|
||||
selectedKeys: ['app:user:1'],
|
||||
},
|
||||
'app:order:1',
|
||||
'app:order:9'
|
||||
);
|
||||
|
||||
expect(unrelatedRenameState.selectedKey).toBe('misc');
|
||||
expect(unrelatedRenameState.selectedKeys).toEqual(['app:user:1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
describe('buildRedisWorkbenchTheme', () => {
|
||||
it('builds dark redis workbench theme', () => {
|
||||
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14 });
|
||||
expect(darkTheme.isDark).toBe(true);
|
||||
expect(darkTheme.panelBg).toMatch(/^rgba\(/);
|
||||
expect(darkTheme.toolbarPrimaryBg).toMatch(/^linear-gradient\(/);
|
||||
expect(darkTheme.actionDangerBg).not.toBe(darkTheme.actionSecondaryBg);
|
||||
expect(darkTheme.treeSelectedBg).not.toBe(darkTheme.treeHoverBg);
|
||||
expect(darkTheme.appBg).toMatch(/rgba\(15, 15, 17,/);
|
||||
expect(darkTheme.panelBg).toMatch(/rgba\(24, 24, 28,/);
|
||||
expect(darkTheme.panelBgStrong).toMatch(/rgba\(31, 31, 36,/);
|
||||
expect(darkTheme.backdropFilter).toBe('blur(14px)');
|
||||
});
|
||||
|
||||
const assertNotEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual === expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nnotExpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertMatch = (value: string, pattern: RegExp, message: string) => {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme = buildRedisWorkbenchTheme({
|
||||
darkMode: true,
|
||||
opacity: 0.72,
|
||||
blur: 14,
|
||||
it('builds light redis workbench theme', () => {
|
||||
const lightTheme = buildRedisWorkbenchTheme({ darkMode: false, opacity: 1, blur: 0 });
|
||||
expect(lightTheme.isDark).toBe(false);
|
||||
expect(lightTheme.panelBg).toMatch(/^rgba\(/);
|
||||
expect(lightTheme.contentEmptyBg).toMatch(/^linear-gradient\(/);
|
||||
expect(lightTheme.textPrimary).not.toBe(lightTheme.textSecondary);
|
||||
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
|
||||
expect(lightTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
|
||||
assertMatch(darkTheme.panelBg, /^rgba\(/, 'dark 主题面板背景应为 rgba');
|
||||
assertMatch(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/, '工具栏主按钮应使用渐变背景');
|
||||
assertNotEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg, '危险态按钮背景不应与普通按钮相同');
|
||||
assertNotEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg, '树节点选中态与悬浮态不应相同');
|
||||
assertMatch(darkTheme.appBg, /rgba\(15, 15, 17,/, 'dark 背景应保持中性黑基底');
|
||||
assertMatch(darkTheme.panelBg, /rgba\(24, 24, 28,/, 'dark 面板背景应保持中性黑灰');
|
||||
assertMatch(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/, 'dark 强面板背景应保持中性黑灰');
|
||||
assertEqual(darkTheme.backdropFilter, 'blur(14px)', 'blur 参数应映射为 backdropFilter');
|
||||
|
||||
const lightTheme = buildRedisWorkbenchTheme({
|
||||
darkMode: false,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
});
|
||||
|
||||
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
|
||||
assertMatch(lightTheme.panelBg, /^rgba\(/, 'light 主题面板背景应为 rgba');
|
||||
assertMatch(lightTheme.contentEmptyBg, /^linear-gradient\(/, 'light 空状态背景应为渐变');
|
||||
assertNotEqual(lightTheme.textPrimary, lightTheme.textSecondary, '主次文本颜色应区分');
|
||||
assertNotEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg, '状态 tag 应区分普通与弱化样式');
|
||||
assertEqual(lightTheme.backdropFilter, 'none', 'blur=0 时 backdropFilter 应为 none');
|
||||
|
||||
console.log('redisViewerWorkbenchTheme tests passed');
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
@@ -25,7 +25,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 6;
|
||||
const PERSIST_VERSION = 7;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
@@ -405,7 +405,7 @@ interface AppState {
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { enabled: boolean; opacity: number; blur: number };
|
||||
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
@@ -450,7 +450,7 @@ interface AppState {
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
@@ -561,9 +561,9 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
|
||||
};
|
||||
|
||||
const sanitizeAppearance = (
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
|
||||
version: number
|
||||
): { enabled: boolean; opacity: number; blur: number } => {
|
||||
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
|
||||
if (!appearance || typeof appearance !== 'object') {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
}
|
||||
@@ -571,6 +571,9 @@ const sanitizeAppearance = (
|
||||
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
|
||||
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
|
||||
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
|
||||
? appearance.useNativeMacWindowControls
|
||||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||||
};
|
||||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
|
||||
23
frontend/src/utils/appearance.test.ts
Normal file
23
frontend/src/utils/appearance.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
|
||||
|
||||
describe('appearance helpers', () => {
|
||||
it('falls back to opaque non-blurred appearance when disabled', () => {
|
||||
expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 });
|
||||
});
|
||||
|
||||
it('preserves configured values when appearance is enabled', () => {
|
||||
expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 });
|
||||
});
|
||||
|
||||
it('caps opacity at full opacity upper bound', () => {
|
||||
expect(normalizeOpacityForPlatform(2)).toBe(1);
|
||||
});
|
||||
|
||||
it('never returns negative blur and formats blur filter correctly', () => {
|
||||
expect(normalizeBlurForPlatform(-4)).toBe(0);
|
||||
expect(blurToFilter(0)).toBeUndefined();
|
||||
expect(blurToFilter(8)).toBe('blur(8px)');
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/macWindow.test.ts
Normal file
47
frontend/src/utils/macWindow.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getMacNativeTitlebarPaddingLeft,
|
||||
getMacNativeTitlebarPaddingRight,
|
||||
shouldHandleMacNativeFullscreenShortcut,
|
||||
shouldSuppressMacNativeEscapeExit,
|
||||
} from './macWindow';
|
||||
|
||||
describe('macWindow helpers', () => {
|
||||
it('uses compact padding when native controls are disabled', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16);
|
||||
expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0);
|
||||
});
|
||||
|
||||
it('reserves traffic-light safe area when native controls are enabled', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96);
|
||||
expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16);
|
||||
});
|
||||
|
||||
it('keeps minimum safe area under small ui scales', () => {
|
||||
expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88);
|
||||
expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12);
|
||||
});
|
||||
|
||||
it('matches Control+Command+F only for mac native mode', () => {
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects conflicting modifiers and non-target keys', () => {
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
|
||||
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses Escape only in mac native fullscreen mode', () => {
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
|
||||
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
42
frontend/src/utils/macWindow.ts
Normal file
42
frontend/src/utils/macWindow.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => {
|
||||
if (!enabled) {
|
||||
return Math.max(12, Math.round(16 * uiScale));
|
||||
}
|
||||
return Math.max(88, Math.round(96 * uiScale));
|
||||
};
|
||||
|
||||
export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => {
|
||||
if (!enabled) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(12, Math.round(16 * uiScale));
|
||||
};
|
||||
|
||||
export const shouldHandleMacNativeFullscreenShortcut = (
|
||||
isMacRuntime: boolean,
|
||||
useNativeMacWindowControls: boolean,
|
||||
event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'key'>,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls) {
|
||||
return false;
|
||||
}
|
||||
if (!event.ctrlKey || !event.metaKey || event.altKey) {
|
||||
return false;
|
||||
}
|
||||
return String(event.key || '').toLowerCase() === 'f';
|
||||
};
|
||||
|
||||
export const shouldSuppressMacNativeEscapeExit = (
|
||||
isMacRuntime: boolean,
|
||||
useNativeMacWindowControls: boolean,
|
||||
isFullscreen: boolean,
|
||||
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
|
||||
): boolean => {
|
||||
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
|
||||
return false;
|
||||
}
|
||||
if (event.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
return String(event.key || '') === 'Escape';
|
||||
};
|
||||
@@ -1,27 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
|
||||
}
|
||||
};
|
||||
describe('buildOverlayWorkbenchTheme', () => {
|
||||
it('builds dark theme tokens', () => {
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
expect(darkTheme.isDark).toBe(true);
|
||||
expect(darkTheme.shellBg).toMatch(/rgba\(15, 15, 17,/);
|
||||
expect(darkTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/);
|
||||
expect(darkTheme.iconColor).toBe('#ffd666');
|
||||
});
|
||||
|
||||
const assertMatch = (value: string, pattern: RegExp, message: string) => {
|
||||
if (!pattern.test(value)) {
|
||||
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const darkTheme = buildOverlayWorkbenchTheme(true);
|
||||
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
|
||||
assertMatch(darkTheme.shellBg, /rgba\(15, 15, 17,/, 'dark 弹层背景应保持中性黑');
|
||||
assertMatch(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/, 'dark section 背景透明度应匹配');
|
||||
assertEqual(darkTheme.iconColor, '#ffd666', 'dark 图标色应为金色强调');
|
||||
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
|
||||
assertMatch(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/, 'light 弹层背景透明度应匹配');
|
||||
assertMatch(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/, 'light section 背景透明度应匹配');
|
||||
assertEqual(lightTheme.iconColor, '#1677ff', 'light 图标色应为蓝色强调');
|
||||
|
||||
console.log('overlayWorkbenchTheme tests passed');
|
||||
it('builds light theme tokens', () => {
|
||||
const lightTheme = buildOverlayWorkbenchTheme(false);
|
||||
expect(lightTheme.isDark).toBe(false);
|
||||
expect(lightTheme.shellBg).toMatch(/rgba\(255,255,255,0\.98\)/);
|
||||
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
|
||||
expect(lightTheme.iconColor).toBe('#1677ff');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user