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 +4 -4
- package/template/AGENTS.md +12 -7
- package/template/README.md +7 -7
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/resources.spec.ts +11 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check.mjs +18 -13
- 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 +645 -109
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +319 -0
- 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/components/resource/resource-import.ts +205 -0
- package/template/apps/web/src/main.tsx +22 -21
- package/template/apps/web/src/platform-client.ts +195 -40
- package/template/apps/web/src/styles.css +455 -220
- package/template/apps/web/test/contracts.test.ts +52 -5
- package/template/apps/web/test/resource-import.test.ts +142 -0
- package/template/openxiangda.config.ts +2 -1
- package/template/package.json +4 -3
- package/template/scripts/verify-template-budget.mjs +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openxiangda-cli",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
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.
|
|
27
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
28
|
-
"openxiangda-mcp": "2.0.0-alpha.
|
|
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",
|
package/template/AGENTS.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
# Application
|
|
1
|
+
# OpenXiangda 2.0 Application Agent Contract
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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.
|
package/template/README.md
CHANGED
|
@@ -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。
|
|
@@ -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
|
});
|
|
@@ -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,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
}
|