openxiangda-cli 2.0.0-alpha.66 → 2.0.0-alpha.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda-cli",
3
- "version": "2.0.0-alpha.66",
3
+ "version": "2.0.0-alpha.68",
4
4
  "description": "Thin application-level CLI for OpenXiangda 2.0.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,4 +17,15 @@ test('clean scaffold renders the shared shell without business fixtures', async
17
17
  await page.goto('/view/clean-app/');
18
18
  await expect(page.getByText('还没有声明数据资源')).toBeVisible();
19
19
  await expect(page.getByText('仪器' + '资源管理')).toHaveCount(0);
20
+ await expect(page.locator('.oxa-topbar')).toHaveCSS('height', '52px');
21
+
22
+ await page.locator('.oxa-current-user').click();
23
+ await expect(page.getByText('跟随系统', { exact: true })).toBeVisible();
24
+ await page.getByText('深色', { exact: true }).click();
25
+ await expect(page.locator('html')).toHaveAttribute('data-oxa-theme', 'dark');
26
+ await expect
27
+ .poll(() => page.evaluate(() => localStorage.getItem('openxiangda.admin.appearance')))
28
+ .toBe('dark');
29
+ await page.reload();
30
+ await expect(page.locator('html')).toHaveAttribute('data-oxa-theme', 'dark');
20
31
  });
@@ -58,7 +58,7 @@ function DocxDocument({ blob }: { blob: Blob }) {
58
58
  };
59
59
  }, [blob]);
60
60
  if (error) {
61
- return <Alert message="Word 文档解析失败" description={error} type="error" showIcon />;
61
+ return <Alert title="Word 文档解析失败" description={error} type="error" showIcon />;
62
62
  }
63
63
  return (
64
64
  <div className="oxa-docx-preview">
@@ -115,7 +115,7 @@ function SpreadsheetDocument({ blob }: { blob: Blob }) {
115
115
  }, [blob]);
116
116
  const rows = sheets[sheetName] || [];
117
117
  if (error) {
118
- return <Alert message="Excel 文件解析失败" description={error} type="error" showIcon />;
118
+ return <Alert title="Excel 文件解析失败" description={error} type="error" showIcon />;
119
119
  }
120
120
  const sheetNames = Object.keys(sheets);
121
121
  if (!sheetNames.length) return <Spin description="正在解析 Excel 文件" />;
@@ -151,7 +151,7 @@ function SpreadsheetDocument({ blob }: { blob: Blob }) {
151
151
  rows.some(row => row.length >= MAX_SPREADSHEET_COLUMNS)) && (
152
152
  <Alert
153
153
  banner
154
- message={`为保证浏览器稳定,仅展示前 ${MAX_SPREADSHEET_ROWS} 行、${MAX_SPREADSHEET_COLUMNS} 列`}
154
+ title={`为保证浏览器稳定,仅展示前 ${MAX_SPREADSHEET_ROWS} 行、${MAX_SPREADSHEET_COLUMNS} 列`}
155
155
  type="warning"
156
156
  />
157
157
  )}
@@ -1,68 +1,358 @@
1
- import { AppstoreOutlined, DatabaseOutlined, LeftOutlined, MenuUnfoldOutlined, SettingOutlined } from '@ant-design/icons';
2
- import { Avatar, Breadcrumb, Button, Empty, Layout, Menu, Space, Typography, type MenuProps } from 'antd';
1
+ import {
2
+ AppstoreOutlined,
3
+ DatabaseOutlined,
4
+ DownOutlined,
5
+ LeftOutlined,
6
+ LogoutOutlined,
7
+ MenuUnfoldOutlined,
8
+ SettingOutlined,
9
+ } from '@ant-design/icons';
10
+ import {
11
+ App,
12
+ Avatar,
13
+ Breadcrumb,
14
+ Button,
15
+ Dropdown,
16
+ Empty,
17
+ Layout,
18
+ Menu,
19
+ Segmented,
20
+ Tabs,
21
+ Typography,
22
+ type MenuProps,
23
+ } from 'antd';
3
24
  import type { ReactNode } from 'react';
4
25
  import { useEffect, useMemo, useState } from 'react';
5
26
  import { useLocation, useNavigate } from 'react-router-dom';
6
27
  import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
7
- import { resolveDirectory } from './platform-client';
28
+ import { useAppearance, type AppearancePreference } from './appearance';
29
+ import { logoutCurrentUser, resolveDirectory } from './platform-client';
8
30
  import { useRuntime } from './runtime';
9
31
 
10
32
  const { Content, Header, Sider } = Layout;
11
- type Definition = { code: string; name: string; capabilities: { read: string }; surface: { generated?: boolean } };
33
+ const HISTORY_STORAGE_KEY = 'openxiangda.admin.route-history';
34
+ const MAX_HISTORY_ITEMS = 8;
35
+
36
+ type Definition = {
37
+ code: string;
38
+ name: string;
39
+ capabilities: { read: string };
40
+ surface: { generated?: boolean };
41
+ };
42
+ type HistoryItem = { path: string; label: string };
43
+
12
44
  const definitions = resourceDefinitions as unknown as Record<string, Definition>;
13
45
 
14
46
  function currentDefinition(pathname: string) {
15
- return Object.values(definitions).find(definition => pathname === `/${definition.code}` || pathname.startsWith(`/${definition.code}/`) || pathname === `/m/${definition.code}` || pathname.startsWith(`/m/${definition.code}/`));
47
+ return Object.values(definitions).find(
48
+ definition =>
49
+ pathname === `/${definition.code}` ||
50
+ pathname.startsWith(`/${definition.code}/`) ||
51
+ pathname === `/m/${definition.code}` ||
52
+ pathname.startsWith(`/m/${definition.code}/`),
53
+ );
54
+ }
55
+
56
+ function routeLabel(pathname: string, definition?: Definition) {
57
+ if (!definition) return '应用首页';
58
+ if (pathname.endsWith('/new')) return `新增${definition.name}`;
59
+ if (pathname.endsWith('/edit')) return `编辑${definition.name}`;
60
+ if (pathname === `/${definition.code}`) return definition.name;
61
+ return `${definition.name}详情`;
62
+ }
63
+
64
+ function readHistory(): HistoryItem[] {
65
+ try {
66
+ const parsed = JSON.parse(
67
+ window.sessionStorage.getItem(HISTORY_STORAGE_KEY) || '[]',
68
+ );
69
+ if (!Array.isArray(parsed)) return [];
70
+ return parsed
71
+ .filter(
72
+ item =>
73
+ item &&
74
+ typeof item.path === 'string' &&
75
+ typeof item.label === 'string',
76
+ )
77
+ .slice(-MAX_HISTORY_ITEMS);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function storeHistory(items: HistoryItem[]) {
84
+ try {
85
+ window.sessionStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(items));
86
+ } catch {
87
+ // Route history is a convenience only; navigation remains authoritative.
88
+ }
16
89
  }
17
90
 
18
91
  export function Shell({ children }: { children: ReactNode }) {
19
92
  const { identity, hasCapability } = useRuntime();
93
+ const { effectiveAppearance, preference, setPreference } = useAppearance();
94
+ const { message } = App.useApp();
20
95
  const location = useLocation();
21
96
  const navigate = useNavigate();
22
97
  const [collapsed, setCollapsed] = useState(false);
23
- const [user, setUser] = useState({ label: '当前用户', description: '平台用户' });
98
+ const [loggingOut, setLoggingOut] = useState(false);
99
+ const [history, setHistory] = useState<HistoryItem[]>(readHistory);
100
+ const [user, setUser] = useState({
101
+ label: '当前用户',
102
+ description: '平台用户',
103
+ });
24
104
  const definition = currentDefinition(location.pathname);
105
+ const currentLabel = routeLabel(location.pathname, definition);
106
+ const accessibleDefinitions = useMemo(
107
+ () =>
108
+ Object.values(definitions).filter(
109
+ item =>
110
+ item.surface.generated !== false &&
111
+ hasCapability(item.capabilities.read),
112
+ ),
113
+ [hasCapability],
114
+ );
115
+ const homePath = accessibleDefinitions[0]
116
+ ? `/${accessibleDefinitions[0].code}`
117
+ : '/';
118
+
25
119
  useEffect(() => {
26
120
  let active = true;
27
- void resolveDirectory('user', [identity.userId]).then(page => {
28
- const entry = page.items[0];
29
- if (active && entry) setUser({ label: entry.label || '当前用户', description: entry.description || '平台用户' });
30
- }).catch(() => undefined);
31
- return () => { active = false; };
121
+ void resolveDirectory('user', [identity.userId])
122
+ .then(page => {
123
+ const entry = page.items[0];
124
+ if (active && entry) {
125
+ setUser({
126
+ label: entry.label || '当前用户',
127
+ description: entry.description || '平台用户',
128
+ });
129
+ }
130
+ })
131
+ .catch(() => undefined);
132
+ return () => {
133
+ active = false;
134
+ };
32
135
  }, [identity.userId]);
33
- const menuItems = useMemo<MenuProps['items']>(() => [
34
- { key: 'overview', icon: <AppstoreOutlined />, label: '总览', children: [{ key: 'home', label: '首页' }] },
35
- {
36
- key: 'resources',
37
- icon: <DatabaseOutlined />,
38
- label: '数据管理',
39
- children: Object.values(definitions)
40
- .filter(item => item.surface.generated !== false && hasCapability(item.capabilities.read))
41
- .map(item => ({ key: `/${item.code}`, icon: <DatabaseOutlined />, label: item.name })),
136
+
137
+ useEffect(() => {
138
+ if (!definition || location.pathname.startsWith('/m/')) return;
139
+ setHistory(current => {
140
+ const next = [
141
+ ...current.filter(item => item.path !== location.pathname),
142
+ { path: location.pathname, label: currentLabel },
143
+ ].slice(-MAX_HISTORY_ITEMS);
144
+ storeHistory(next);
145
+ return next;
146
+ });
147
+ }, [currentLabel, definition, location.pathname]);
148
+
149
+ const menuItems = useMemo<MenuProps['items']>(
150
+ () => [
151
+ {
152
+ key: 'overview',
153
+ icon: <AppstoreOutlined />,
154
+ label: '总览',
155
+ children: [{ key: 'home', label: '首页' }],
156
+ },
157
+ {
158
+ key: 'resources',
159
+ icon: <DatabaseOutlined />,
160
+ label: '数据管理',
161
+ children: accessibleDefinitions.map(item => ({
162
+ key: `/${item.code}`,
163
+ icon: <DatabaseOutlined />,
164
+ label: item.name,
165
+ })),
166
+ },
167
+ {
168
+ key: 'settings',
169
+ icon: <SettingOutlined />,
170
+ label: '系统设置',
171
+ children: [
172
+ { key: 'settings-disabled', label: '应用设置', disabled: true },
173
+ ],
174
+ },
175
+ ],
176
+ [accessibleDefinitions],
177
+ );
178
+
179
+ const closeHistory = (targetPath: string) => {
180
+ const targetIndex = history.findIndex(item => item.path === targetPath);
181
+ const remaining = history.filter(item => item.path !== targetPath);
182
+ storeHistory(remaining);
183
+ setHistory(remaining);
184
+ if (targetPath !== location.pathname) return;
185
+ const fallback =
186
+ remaining[Math.min(Math.max(targetIndex, 0), remaining.length - 1)] ||
187
+ remaining.at(-1);
188
+ navigate(fallback?.path || homePath);
189
+ };
190
+
191
+ const handleLogout = async () => {
192
+ setLoggingOut(true);
193
+ try {
194
+ await logoutCurrentUser();
195
+ window.location.assign('/login');
196
+ } catch (error) {
197
+ message.error(error instanceof Error ? error.message : '退出登录失败');
198
+ setLoggingOut(false);
199
+ }
200
+ };
201
+
202
+ const userMenu: MenuProps = {
203
+ items: [
204
+ { type: 'divider' },
205
+ {
206
+ danger: true,
207
+ icon: <LogoutOutlined />,
208
+ key: 'logout',
209
+ label: loggingOut ? '正在退出…' : '退出登录',
210
+ },
211
+ ],
212
+ onClick: ({ key }) => {
213
+ if (key === 'logout' && !loggingOut) void handleLogout();
42
214
  },
43
- { key: 'settings', icon: <SettingOutlined />, label: '系统设置', children: [{ key: 'settings-disabled', label: '应用设置', disabled: true }] },
44
- ], [hasCapability]);
215
+ };
216
+
45
217
  return (
46
218
  <Layout className="oxa-app-layout">
47
- <Sider className="oxa-sider" collapsed={collapsed} collapsedWidth={76} collapsible onBreakpoint={setCollapsed} theme="light" trigger={null} width={240}>
48
- <div className="oxa-brand"><div aria-hidden className="oxa-brand-mark"><span /></div>{!collapsed && <div className="oxa-brand-copy"><strong>OpenXiangda 应用</strong><span>标准资源管理工作台</span></div>}</div>
49
- <Menu className="oxa-menu" defaultOpenKeys={['overview', 'resources', 'settings']} inlineCollapsed={collapsed} items={menuItems} mode="inline" onClick={({ key }) => {
50
- if (key === 'home') navigate(Object.keys(definitions)[0] ? `/${Object.keys(definitions)[0]}` : '/');
51
- else if (typeof key === 'string' && key.startsWith('/')) navigate(key);
52
- }} selectedKeys={definition ? [`/${definition.code}`] : []} />
53
- <div className="oxa-sider-footer"><Button aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'} block icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />} onClick={() => setCollapsed(value => !value)} type="text">{!collapsed && '收起侧边栏'}</Button></div>
219
+ <Sider
220
+ className="oxa-sider"
221
+ collapsed={collapsed}
222
+ collapsedWidth={64}
223
+ collapsible
224
+ onBreakpoint={setCollapsed}
225
+ theme={effectiveAppearance}
226
+ trigger={null}
227
+ width={228}
228
+ >
229
+ <div className="oxa-brand">
230
+ <div aria-hidden className="oxa-brand-mark">
231
+ <span />
232
+ </div>
233
+ {!collapsed && (
234
+ <div className="oxa-brand-copy">
235
+ <strong>OpenXiangda 应用</strong>
236
+ <span>标准资源管理工作台</span>
237
+ </div>
238
+ )}
239
+ </div>
240
+ <Menu
241
+ className="oxa-menu"
242
+ defaultOpenKeys={['overview', 'resources', 'settings']}
243
+ inlineCollapsed={collapsed}
244
+ items={menuItems}
245
+ mode="inline"
246
+ onClick={({ key }) => {
247
+ if (key === 'home') navigate(homePath);
248
+ else if (typeof key === 'string' && key.startsWith('/')) navigate(key);
249
+ }}
250
+ selectedKeys={definition ? [`/${definition.code}`] : []}
251
+ theme={effectiveAppearance}
252
+ />
253
+ <div className="oxa-sider-footer">
254
+ <Button
255
+ aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
256
+ block
257
+ icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />}
258
+ onClick={() => setCollapsed(value => !value)}
259
+ type="text"
260
+ >
261
+ {!collapsed && '收起侧边栏'}
262
+ </Button>
263
+ </div>
54
264
  </Sider>
55
265
  <Layout className="oxa-workspace">
56
266
  <Header className="oxa-topbar">
57
- <div className="oxa-topbar-page"><Breadcrumb items={[{ title: '首页' }, ...(definition ? [{ title: definition.name }] : [])]} /><Typography.Title level={3}>{definition?.name || '应用首页'}</Typography.Title></div>
58
- <Space className="oxa-current-user" size={12}><Avatar className="oxa-current-user-avatar" size={42}>{user.label.slice(0, 1)}</Avatar><div className="oxa-current-user-copy"><strong>{user.label}</strong><span>{user.description}</span></div></Space>
267
+ <Breadcrumb
268
+ items={[
269
+ { title: '首页', onClick: () => navigate(homePath) },
270
+ ...(definition ? [{ title: currentLabel }] : []),
271
+ ]}
272
+ />
273
+ <Dropdown
274
+ menu={userMenu}
275
+ popupRender={menus => (
276
+ <div className="oxa-user-dropdown">
277
+ <div className="oxa-user-dropdown-profile">
278
+ <Avatar className="oxa-current-user-avatar" size={40}>
279
+ {user.label.slice(0, 1)}
280
+ </Avatar>
281
+ <div>
282
+ <strong>{user.label}</strong>
283
+ <span>{user.description}</span>
284
+ <small>{identity.userId}</small>
285
+ </div>
286
+ </div>
287
+ <div className="oxa-user-dropdown-roles">
288
+ <span>当前角色</span>
289
+ <Typography.Text ellipsis>
290
+ {identity.isAppSuperAdmin
291
+ ? '应用管理员'
292
+ : identity.roleCodes.join('、') || '普通成员'}
293
+ </Typography.Text>
294
+ </div>
295
+ <div className="oxa-user-dropdown-theme">
296
+ <span>外观</span>
297
+ <Segmented
298
+ block
299
+ onChange={value =>
300
+ setPreference(value as AppearancePreference)
301
+ }
302
+ options={[
303
+ { label: '跟随系统', value: 'system' },
304
+ { label: '浅色', value: 'light' },
305
+ { label: '深色', value: 'dark' },
306
+ ]}
307
+ size="small"
308
+ value={preference}
309
+ />
310
+ </div>
311
+ {menus}
312
+ </div>
313
+ )}
314
+ trigger={['hover', 'click']}
315
+ >
316
+ <button className="oxa-current-user" type="button">
317
+ <Avatar className="oxa-current-user-avatar" size={30}>
318
+ {user.label.slice(0, 1)}
319
+ </Avatar>
320
+ <strong>{user.label}</strong>
321
+ <DownOutlined className="oxa-current-user-chevron" />
322
+ </button>
323
+ </Dropdown>
59
324
  </Header>
60
- <Content className="oxa-content"><main className="oxa-main">{children}</main></Content>
325
+ {history.length > 0 && (
326
+ <nav aria-label="页面访问历史" className="oxa-route-history">
327
+ <Tabs
328
+ activeKey={location.pathname}
329
+ hideAdd
330
+ items={history.map(item => ({
331
+ closable: history.length > 1,
332
+ key: item.path,
333
+ label: item.label,
334
+ }))}
335
+ onChange={navigate}
336
+ onEdit={(target, action) => {
337
+ if (action === 'remove') closeHistory(String(target));
338
+ }}
339
+ size="small"
340
+ type="editable-card"
341
+ />
342
+ </nav>
343
+ )}
344
+ <Content className="oxa-content">
345
+ <main className="oxa-main">{children}</main>
346
+ </Content>
61
347
  </Layout>
62
348
  </Layout>
63
349
  );
64
350
  }
65
351
 
66
352
  export function EmptyApplicationPage() {
67
- return <Shell><Empty description="还没有声明数据资源;请在 platform/data 中添加 DataResource" /></Shell>;
353
+ return (
354
+ <Shell>
355
+ <Empty description="还没有声明数据资源;请在 platform/data 中添加 DataResource" />
356
+ </Shell>
357
+ );
68
358
  }
@@ -0,0 +1,147 @@
1
+ import {
2
+ App as AntdApp,
3
+ ConfigProvider,
4
+ theme as antdTheme,
5
+ type ThemeConfig,
6
+ } from 'antd';
7
+ import zhCN from 'antd/locale/zh_CN';
8
+ import {
9
+ createContext,
10
+ useContext,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useState,
15
+ type ReactNode,
16
+ } from 'react';
17
+
18
+ export type AppearancePreference = 'system' | 'light' | 'dark';
19
+ export type EffectiveAppearance = 'light' | 'dark';
20
+
21
+ const STORAGE_KEY = 'openxiangda.admin.appearance';
22
+ const AppearanceContext = createContext<{
23
+ preference: AppearancePreference;
24
+ effectiveAppearance: EffectiveAppearance;
25
+ setPreference: (value: AppearancePreference) => void;
26
+ } | null>(null);
27
+
28
+ function storedPreference(): AppearancePreference {
29
+ try {
30
+ const value = window.localStorage.getItem(STORAGE_KEY);
31
+ return value === 'light' || value === 'dark' || value === 'system'
32
+ ? value
33
+ : 'system';
34
+ } catch {
35
+ return 'system';
36
+ }
37
+ }
38
+
39
+ function systemPrefersDark() {
40
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
41
+ }
42
+
43
+ function PlatformThemeCssVariables() {
44
+ const { token } = antdTheme.useToken();
45
+ useLayoutEffect(() => {
46
+ const root = document.documentElement;
47
+ const variables: Record<string, string> = {
48
+ '--oxa-shell-bg': token.colorBgLayout,
49
+ '--oxa-shell-surface': token.colorBgContainer,
50
+ '--oxa-shell-elevated': token.colorBgElevated,
51
+ '--oxa-shell-subtle': token.colorFillAlter,
52
+ '--oxa-shell-hover': token.colorFillSecondary,
53
+ '--oxa-shell-border': token.colorBorderSecondary,
54
+ '--oxa-shell-border-strong': token.colorBorder,
55
+ '--oxa-shell-text': token.colorText,
56
+ '--oxa-shell-text-secondary': token.colorTextSecondary,
57
+ '--oxa-shell-text-tertiary': token.colorTextTertiary,
58
+ '--oxa-shell-text-disabled': token.colorTextDisabled,
59
+ '--oxa-shell-primary': token.colorPrimary,
60
+ '--oxa-shell-primary-bg': token.colorPrimaryBg,
61
+ '--oxa-shell-primary-border': token.colorPrimaryBorder,
62
+ '--oxa-shell-error': token.colorError,
63
+ '--oxa-shell-error-bg': token.colorErrorBg,
64
+ '--oxa-shell-error-border': token.colorErrorBorder,
65
+ '--oxa-shell-mask': token.colorBgMask,
66
+ '--oxa-shell-shadow': token.boxShadowTertiary,
67
+ };
68
+ for (const [name, value] of Object.entries(variables)) {
69
+ root.style.setProperty(name, value);
70
+ }
71
+ }, [token]);
72
+ return null;
73
+ }
74
+
75
+ export function AppearanceProvider({ children }: { children: ReactNode }) {
76
+ const [preference, setPreferenceState] = useState<AppearancePreference>(
77
+ storedPreference,
78
+ );
79
+ const [systemDark, setSystemDark] = useState(systemPrefersDark);
80
+
81
+ useEffect(() => {
82
+ const query = window.matchMedia('(prefers-color-scheme: dark)');
83
+ const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches);
84
+ query.addEventListener('change', onChange);
85
+ return () => query.removeEventListener('change', onChange);
86
+ }, []);
87
+
88
+ const effectiveAppearance: EffectiveAppearance =
89
+ preference === 'system' ? (systemDark ? 'dark' : 'light') : preference;
90
+
91
+ useLayoutEffect(() => {
92
+ document.documentElement.dataset.oxaTheme = effectiveAppearance;
93
+ document.documentElement.style.colorScheme = effectiveAppearance;
94
+ }, [effectiveAppearance]);
95
+
96
+ const setPreference = (value: AppearancePreference) => {
97
+ setPreferenceState(value);
98
+ try {
99
+ window.localStorage.setItem(STORAGE_KEY, value);
100
+ } catch {
101
+ // A blocked storage API must not prevent the in-memory theme switch.
102
+ }
103
+ };
104
+
105
+ const theme = useMemo<ThemeConfig>(
106
+ () => ({
107
+ algorithm:
108
+ effectiveAppearance === 'dark'
109
+ ? antdTheme.darkAlgorithm
110
+ : antdTheme.defaultAlgorithm,
111
+ cssVar: { key: effectiveAppearance, prefix: 'oxa' },
112
+ token: {
113
+ borderRadius: 6,
114
+ colorPrimary: '#1677ff',
115
+ fontSize: 14,
116
+ },
117
+ components: {
118
+ Card: { headerFontSize: 16 },
119
+ Layout: { headerHeight: 52 },
120
+ Menu: { itemBorderRadius: 6, itemHeight: 40 },
121
+ },
122
+ }),
123
+ [effectiveAppearance],
124
+ );
125
+
126
+ const value = useMemo(
127
+ () => ({ preference, effectiveAppearance, setPreference }),
128
+ [effectiveAppearance, preference],
129
+ );
130
+
131
+ return (
132
+ <AppearanceContext.Provider value={value}>
133
+ <ConfigProvider componentSize="medium" locale={zhCN} theme={theme}>
134
+ <AntdApp>
135
+ <PlatformThemeCssVariables />
136
+ {children}
137
+ </AntdApp>
138
+ </ConfigProvider>
139
+ </AppearanceContext.Provider>
140
+ );
141
+ }
142
+
143
+ export function useAppearance() {
144
+ const value = useContext(AppearanceContext);
145
+ if (!value) throw new Error('OPENXIANGDA_APPEARANCE_NOT_READY');
146
+ return value;
147
+ }