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 +1 -1
- package/template/apps/web/e2e/resources.spec.ts +11 -0
- package/template/apps/web/src/FilePreviewPage.tsx +3 -3
- package/template/apps/web/src/Shell.tsx +323 -33
- package/template/apps/web/src/appearance.tsx +147 -0
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +426 -59
- package/template/apps/web/src/components/resource/StandardResourcePages.tsx +13 -52
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +1 -1
- package/template/apps/web/src/main.tsx +22 -21
- package/template/apps/web/src/platform-client.ts +16 -3
- package/template/apps/web/src/styles.css +425 -219
- package/template/apps/web/test/contracts.test.ts +42 -0
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
154
|
+
title={`为保证浏览器稳定,仅展示前 ${MAX_SPREADSHEET_ROWS} 行、${MAX_SPREADSHEET_COLUMNS} 列`}
|
|
155
155
|
type="warning"
|
|
156
156
|
/>
|
|
157
157
|
)}
|
|
@@ -1,68 +1,358 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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 [
|
|
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])
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
215
|
+
};
|
|
216
|
+
|
|
45
217
|
return (
|
|
46
218
|
<Layout className="oxa-app-layout">
|
|
47
|
-
<Sider
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
<
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|