openxiangda-cli 2.0.0-alpha.67 → 2.0.0-alpha.69

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.67",
3
+ "version": "2.0.0-alpha.69",
4
4
  "description": "Thin application-level CLI for OpenXiangda 2.0.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,9 +23,9 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@oclif/core": "4.13.3",
26
- "openxiangda-contracts": "2.0.0-alpha.31",
27
- "openxiangda-devkit-core": "2.0.0-alpha.41",
28
- "openxiangda-mcp": "2.0.0-alpha.41"
26
+ "openxiangda-contracts": "2.0.0-alpha.32",
27
+ "openxiangda-devkit-core": "2.0.0-alpha.42",
28
+ "openxiangda-mcp": "2.0.0-alpha.42"
29
29
  },
30
30
  "devDependencies": {
31
31
  "tsx": "4.23.12",
@@ -1,8 +1,13 @@
1
- # Application development rules
1
+ # OpenXiangda 2.0 Application Agent Contract
2
2
 
3
- - Build normal application code directly; generated Skills and historical templates are not design authority.
4
- - Keep CRUD in the replaceable browser Data API adapter under `/service`.
5
- - Put only real transactional or integration actions in Nest under `/api`.
6
- - Read current-user role/capability unions from the platform. UI guards are presentation only; server authorization is authoritative.
7
- - Use the declared `reference` mapping for members, departments, and related resources; persist stable IDs, not display labels.
8
- - Run `openxiangda check` before delivery.
3
+ - Use only the workspace-pinned CLI: `pnpm openxiangda <command>`. Never invoke a bare global `openxiangda` inside a 2.0 task.
4
+ - Use Vite, React Router, Refine Core and Ant Design. Do not add Umi, ProComponents or another admin shell.
5
+ - Declare each resource once in `openxiangda.config.ts`. Resource codes are lower kebab-case. The same declaration owns storage, Surface metadata, permissions and generated AI Schema.
6
+ - Ordinary list/get/create/update/delete, filters, export and batch operations use the platform Native Data API. Do not create Function CRUD or NestJS wrappers.
7
+ - Add NestJS only for a named business action that needs a cross-resource transaction, an invariant or an external side effect.
8
+ - A NestJS backend declares only `enabled`, `isolation: 'shared' | 'dedicated'` and `resourceProfile: 'light' | 'standard'`. Never put raw Kubernetes resources, replicas, ports or environment maps in application metadata; the platform owns capacity and scaling.
9
+ - Use the current logged-in user and the union of application roles. Do not select RoleSession, persist platform Token or implement a second authorization path.
10
+ - Declare restricted field read, create and update permissions explicitly. There is no `write` fallback; generated update permission is denied unless explicitly granted.
11
+ - Desktop and mobile pages share values, validation and authorization, but use separate renderers. Members, departments, resources and attachments use platform-owned selectors and stable IDs.
12
+ - Do not add compatibility aliases, migration branches or silent fallbacks for an earlier 2.0 alpha contract. Replace an incorrect contract and regenerate the application.
13
+ - Run `pnpm openxiangda check` after contract changes. Deploy with `pnpm openxiangda deploy`, inspect with `pnpm openxiangda status` and `pnpm openxiangda logs`, and use the platform rollback command rather than mutating K3s directly.
@@ -3,10 +3,10 @@
3
3
  这是干净的资源 CRUD 模板:Vite + React Router + Refine Core + Ant Design 前端、NestJS 平台启动层,以及平台 Data/AuthZ 声明。
4
4
 
5
5
  ```bash
6
- openxiangda login --base-url https://platform.example.com
7
- openxiangda create visitor-reservations
6
+ pnpm dlx openxiangda-cli@latest login --base-url https://platform.example.com
7
+ pnpm dlx openxiangda-cli@latest create visitor-reservations
8
8
  cd visitor-reservations
9
- openxiangda dev
9
+ pnpm openxiangda dev
10
10
  ```
11
11
 
12
12
  在 `platform/data` 中声明 `DataResource` 和字段即可生成标准后台列表、筛选、表单、详情、审计、文件上传和独立移动端页面。成员、部门和资源关系分别使用 `reference: { kind: 'directory-user' }`、`directory-department`、`resource`,只保存稳定 ID,不复制目录数据。
@@ -16,10 +16,10 @@ openxiangda dev
16
16
  前端读取平台 current-user 的角色/能力并集进行展示保护,服务端始终权威。字段策略区分 create/update;无权字段在提交前从 payload 删除。
17
17
 
18
18
  ```bash
19
- openxiangda check
20
- openxiangda deploy
21
- openxiangda status
22
- openxiangda logs
19
+ pnpm openxiangda check
20
+ pnpm openxiangda deploy
21
+ pnpm openxiangda status
22
+ pnpm openxiangda logs
23
23
  ```
24
24
 
25
25
  `deploy` 使用本模板的 `apps/server/Dockerfile` 自动构建并推送 `linux/amd64` 镜像,再把不可变 digest 写入 AppPackage。纯 CRUD 应用可以保持 backend disabled;复杂事务再启用 Nest。
@@ -15,7 +15,7 @@
15
15
  "@nestjs/common": "11.1.29",
16
16
  "@nestjs/core": "11.1.29",
17
17
  "@nestjs/platform-fastify": "11.1.29",
18
- "openxiangda-nest": "2.0.0-alpha.37",
18
+ "openxiangda-nest": "2.0.0-alpha.38",
19
19
  "reflect-metadata": "0.2.2",
20
20
  "rxjs": "7.8.2"
21
21
  },
@@ -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
  });
@@ -16,7 +16,7 @@
16
16
  "antd": "6.4.3",
17
17
  "dayjs": "1.11.18",
18
18
  "docx-preview": "0.3.7",
19
- "openxiangda-contracts": "2.0.0-alpha.31",
19
+ "openxiangda-contracts": "2.0.0-alpha.32",
20
20
  "react": "19.2.8",
21
21
  "react-dom": "19.2.8",
22
22
  "react-router-dom": "7.8.2",
@@ -6,26 +6,31 @@ const files = [];
6
6
  function visit(directory) {
7
7
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
8
8
  const path = join(directory, entry.name);
9
- if (
10
- entry.isDirectory() &&
11
- !['.umi', '.umi-production', '.turbopack'].includes(entry.name)
12
- )
13
- visit(path);
9
+ if (entry.isDirectory() && !['.umi', '.umi-production', '.turbopack'].includes(entry.name)) visit(path);
14
10
  else if (/\.(?:ts|tsx)$/.test(entry.name)) files.push(path);
15
11
  }
16
12
  }
17
13
  visit(join(root, 'src'));
18
- const lines = files.reduce(
19
- (total, file) => total + readFileSync(file, 'utf8').split(/\r?\n/).length,
20
- 0
21
- );
14
+ const lines = files.reduce((total, file) => total + readFileSync(file, 'utf8').split(/\r?\n/).length, 0);
22
15
  // The shared Surface renderer is deliberately counted as application source;
23
16
  // keep its small budget explicit instead of hiding it in generated output.
24
17
  if (files.length > 20) throw new Error(`WEB_SOURCE_FILE_BUDGET_EXCEEDED:${files.length}>20`);
25
- // Mobile shells, Surface filters, and generated resource CRUD are shared application infrastructure.
26
- if (lines > 5_600) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>5600`);
27
- const source = files.map(file => readFileSync(file, 'utf8')).join('\n');
28
- for (const marker of ['@umijs/', 'openxiangda-' + 'admin', 'openxiangda-' + 'user', 'instrument_query', 'instrument_save', 'function.invoke', 'role' + '-session', 'college-' + 'am', 'user-' + 'wang', 'dept-' + 'am-test']) {
18
+ // Mobile shells, Surface filters, generated resource CRUD, atomic batch actions,
19
+ // and import preview are shared application infrastructure.
20
+ if (lines > 6_000) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>6000`);
21
+ const source = files.map((file) => readFileSync(file, 'utf8')).join('\n');
22
+ for (const marker of [
23
+ '@umijs/',
24
+ 'openxiangda-' + 'admin',
25
+ 'openxiangda-' + 'user',
26
+ 'instrument_query',
27
+ 'instrument_save',
28
+ 'function.invoke',
29
+ 'role' + '-session',
30
+ 'college-' + 'am',
31
+ 'user-' + 'wang',
32
+ 'dept-' + 'am-test',
33
+ ]) {
29
34
  if (source.includes(marker)) throw new Error(`WEB_FORBIDDEN_MARKER:${marker}`);
30
35
  }
31
36
  const playwright = readFileSync(join(root, 'playwright.config.ts'), 'utf8');
@@ -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
  }