openxiangda-cli 2.0.0-alpha.61 → 2.0.0-alpha.66
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/dist/create-workspace.d.ts.map +1 -1
- package/dist/create-workspace.js +1 -0
- package/dist/create-workspace.js.map +1 -1
- package/package.json +4 -4
- package/template/AGENTS.md +1 -1
- package/template/README.md +7 -7
- package/template/apps/server/Dockerfile +2 -1
- package/template/apps/server/package.json +1 -1
- package/template/apps/server/src/app.module.ts +1 -3
- package/template/apps/server/test/smoke.test.ts +6 -23
- package/template/apps/web/e2e/resources.spec.ts +20 -0
- package/template/apps/web/index.html +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +298 -41
- package/template/apps/web/src/Shell.tsx +39 -211
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +229 -16
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +142 -2
- package/template/apps/web/src/data-provider.ts +23 -102
- package/template/apps/web/src/main.tsx +35 -83
- package/template/apps/web/src/platform-client.ts +112 -424
- package/template/apps/web/src/runtime-meta.ts +1 -1
- package/template/apps/web/src/styles.css +285 -3
- package/template/apps/web/test/contracts.test.ts +43 -769
- package/template/openxiangda.config.ts +14 -81
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +7 -804
- package/template/apps/server/src/instrument-context.controller.ts +0 -25
- package/template/apps/web/e2e/instruments.spec.ts +0 -1338
- package/template/apps/web/src/CollegePage.tsx +0 -181
- package/template/apps/web/src/InstrumentDetailPage.tsx +0 -176
- package/template/apps/web/src/InstrumentForm.tsx +0 -380
- package/template/apps/web/src/InstrumentFormPage.tsx +0 -82
- package/template/apps/web/src/InstrumentListPage.tsx +0 -457
- package/template/apps/web/src/fields.ts +0 -18
- package/template/apps/web/src/instrument.ts +0 -56
- package/template/platform/data/colleges.ts +0 -21
- package/template/platform/data/instrument-surface.ts +0 -125
- package/template/platform/data/instruments.ts +0 -83
|
@@ -1,69 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
AppstoreOutlined,
|
|
4
|
-
BookOutlined,
|
|
5
|
-
DownOutlined,
|
|
6
|
-
ExperimentOutlined,
|
|
7
|
-
HomeOutlined,
|
|
8
|
-
LeftOutlined,
|
|
9
|
-
MenuUnfoldOutlined,
|
|
10
|
-
SettingOutlined,
|
|
11
|
-
TeamOutlined,
|
|
12
|
-
} from '@ant-design/icons';
|
|
13
|
-
import {
|
|
14
|
-
Avatar,
|
|
15
|
-
Breadcrumb,
|
|
16
|
-
Button,
|
|
17
|
-
Layout,
|
|
18
|
-
Menu,
|
|
19
|
-
Space,
|
|
20
|
-
Typography,
|
|
21
|
-
type MenuProps,
|
|
22
|
-
} from 'antd';
|
|
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';
|
|
23
3
|
import type { ReactNode } from 'react';
|
|
24
4
|
import { useEffect, useMemo, useState } from 'react';
|
|
25
5
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
6
|
+
import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
|
|
26
7
|
import { resolveDirectory } from './platform-client';
|
|
27
8
|
import { useRuntime } from './runtime';
|
|
28
9
|
|
|
29
10
|
const { Content, Header, Sider } = Layout;
|
|
11
|
+
type Definition = { code: string; name: string; capabilities: { read: string }; surface: { generated?: boolean } };
|
|
12
|
+
const definitions = resourceDefinitions as unknown as Record<string, Definition>;
|
|
30
13
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
function pageMeta(pathname: string): PageMeta {
|
|
34
|
-
if (pathname === '/colleges') {
|
|
35
|
-
return {
|
|
36
|
-
title: '学院字典',
|
|
37
|
-
breadcrumbs: ['首页', '基础数据', '学院字典'],
|
|
38
|
-
selectedKey: '/colleges',
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
if (pathname.endsWith('/new')) {
|
|
42
|
-
return {
|
|
43
|
-
title: '新增仪器',
|
|
44
|
-
breadcrumbs: ['首页', '仪器管理', '仪器资源', '新增仪器'],
|
|
45
|
-
selectedKey: '/instruments',
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
if (pathname.endsWith('/edit')) {
|
|
49
|
-
return {
|
|
50
|
-
title: '编辑仪器',
|
|
51
|
-
breadcrumbs: ['首页', '仪器管理', '仪器资源', '编辑仪器'],
|
|
52
|
-
selectedKey: '/instruments',
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
if (/^\/instruments\/[^/]+$/.test(pathname)) {
|
|
56
|
-
return {
|
|
57
|
-
title: '仪器详情',
|
|
58
|
-
breadcrumbs: ['首页', '仪器管理', '仪器资源', '仪器详情'],
|
|
59
|
-
selectedKey: '/instruments',
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
return {
|
|
63
|
-
title: '仪器资源',
|
|
64
|
-
breadcrumbs: ['首页', '仪器管理', '仪器资源'],
|
|
65
|
-
selectedKey: '/instruments',
|
|
66
|
-
};
|
|
14
|
+
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}/`));
|
|
67
16
|
}
|
|
68
17
|
|
|
69
18
|
export function Shell({ children }: { children: ReactNode }) {
|
|
@@ -72,169 +21,48 @@ export function Shell({ children }: { children: ReactNode }) {
|
|
|
72
21
|
const navigate = useNavigate();
|
|
73
22
|
const [collapsed, setCollapsed] = useState(false);
|
|
74
23
|
const [user, setUser] = useState({ label: '当前用户', description: '平台用户' });
|
|
75
|
-
const
|
|
76
|
-
|
|
24
|
+
const definition = currentDefinition(location.pathname);
|
|
77
25
|
useEffect(() => {
|
|
78
26
|
let active = true;
|
|
79
|
-
void resolveDirectory('user', [identity.userId])
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
label: entry.label || '当前用户',
|
|
85
|
-
description: entry.description || '平台用户',
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
})
|
|
89
|
-
.catch(() => {
|
|
90
|
-
if (active) setUser({ label: '当前用户', description: '平台用户' });
|
|
91
|
-
});
|
|
92
|
-
return () => {
|
|
93
|
-
active = false;
|
|
94
|
-
};
|
|
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; };
|
|
95
32
|
}, [identity.userId]);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
key: 'instrument-management',
|
|
109
|
-
icon: <ExperimentOutlined />,
|
|
110
|
-
label: '仪器管理',
|
|
111
|
-
children: [
|
|
112
|
-
{
|
|
113
|
-
key: '/instruments',
|
|
114
|
-
icon: <ExperimentOutlined />,
|
|
115
|
-
label: '仪器资源',
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
key: 'open-records',
|
|
119
|
-
icon: <BookOutlined />,
|
|
120
|
-
label: '开放记录',
|
|
121
|
-
disabled: true,
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
key: 'base-data',
|
|
127
|
-
icon: <ApartmentOutlined />,
|
|
128
|
-
label: '基础数据',
|
|
129
|
-
children: hasCapability('app:instrument-center:data:colleges:read')
|
|
130
|
-
? [
|
|
131
|
-
{
|
|
132
|
-
key: '/colleges',
|
|
133
|
-
icon: <ApartmentOutlined />,
|
|
134
|
-
label: '学院字典',
|
|
135
|
-
},
|
|
136
|
-
]
|
|
137
|
-
: [],
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
key: 'system-settings',
|
|
141
|
-
icon: <SettingOutlined />,
|
|
142
|
-
label: '系统设置',
|
|
143
|
-
children: [
|
|
144
|
-
{
|
|
145
|
-
key: 'application-admins',
|
|
146
|
-
icon: <TeamOutlined />,
|
|
147
|
-
label: '应用管理员',
|
|
148
|
-
disabled: true,
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
},
|
|
152
|
-
],
|
|
153
|
-
[hasCapability]
|
|
154
|
-
);
|
|
155
|
-
|
|
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 })),
|
|
42
|
+
},
|
|
43
|
+
{ key: 'settings', icon: <SettingOutlined />, label: '系统设置', children: [{ key: 'settings-disabled', label: '应用设置', disabled: true }] },
|
|
44
|
+
], [hasCapability]);
|
|
156
45
|
return (
|
|
157
46
|
<Layout className="oxa-app-layout">
|
|
158
|
-
<Sider
|
|
159
|
-
className="oxa-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
theme="light"
|
|
166
|
-
trigger={null}
|
|
167
|
-
width={240}
|
|
168
|
-
>
|
|
169
|
-
<div className="oxa-brand">
|
|
170
|
-
<div aria-hidden className="oxa-brand-mark">
|
|
171
|
-
<span />
|
|
172
|
-
</div>
|
|
173
|
-
{!collapsed && (
|
|
174
|
-
<div className="oxa-brand-copy">
|
|
175
|
-
<strong>仪器资源管理</strong>
|
|
176
|
-
<span>仪器、学院与开放管理</span>
|
|
177
|
-
</div>
|
|
178
|
-
)}
|
|
179
|
-
</div>
|
|
180
|
-
<Menu
|
|
181
|
-
className="oxa-menu"
|
|
182
|
-
defaultOpenKeys={[
|
|
183
|
-
'overview',
|
|
184
|
-
'instrument-management',
|
|
185
|
-
'base-data',
|
|
186
|
-
'system-settings',
|
|
187
|
-
]}
|
|
188
|
-
inlineCollapsed={collapsed}
|
|
189
|
-
items={menuItems}
|
|
190
|
-
mode="inline"
|
|
191
|
-
onClick={({ key }) => {
|
|
192
|
-
if (key === 'workbench' || key === '/instruments') {
|
|
193
|
-
navigate('/instruments');
|
|
194
|
-
} else if (key === '/colleges') {
|
|
195
|
-
navigate('/colleges');
|
|
196
|
-
}
|
|
197
|
-
}}
|
|
198
|
-
selectedKeys={[meta.selectedKey]}
|
|
199
|
-
/>
|
|
200
|
-
<div className="oxa-sider-footer">
|
|
201
|
-
<Button
|
|
202
|
-
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
|
203
|
-
block
|
|
204
|
-
icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />}
|
|
205
|
-
onClick={() => setCollapsed(value => !value)}
|
|
206
|
-
type="text"
|
|
207
|
-
>
|
|
208
|
-
{!collapsed && '收起侧边栏'}
|
|
209
|
-
</Button>
|
|
210
|
-
</div>
|
|
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>
|
|
211
54
|
</Sider>
|
|
212
55
|
<Layout className="oxa-workspace">
|
|
213
56
|
<Header className="oxa-topbar">
|
|
214
|
-
<div className="oxa-topbar-page">
|
|
215
|
-
|
|
216
|
-
<Typography.Title
|
|
217
|
-
data-testid={meta.title === '仪器资源' ? 'instrument-title' : undefined}
|
|
218
|
-
level={3}
|
|
219
|
-
>
|
|
220
|
-
{meta.title}
|
|
221
|
-
</Typography.Title>
|
|
222
|
-
</div>
|
|
223
|
-
<Space className="oxa-current-user" size={12}>
|
|
224
|
-
<Avatar className="oxa-current-user-avatar" size={42}>
|
|
225
|
-
{user.label.slice(0, 1)}
|
|
226
|
-
</Avatar>
|
|
227
|
-
<div className="oxa-current-user-copy">
|
|
228
|
-
<strong>{user.label}</strong>
|
|
229
|
-
<span>{user.description}</span>
|
|
230
|
-
</div>
|
|
231
|
-
<DownOutlined className="oxa-current-user-chevron" />
|
|
232
|
-
</Space>
|
|
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>
|
|
233
59
|
</Header>
|
|
234
|
-
<Content className="oxa-content">
|
|
235
|
-
<main className="oxa-main">{children}</main>
|
|
236
|
-
</Content>
|
|
60
|
+
<Content className="oxa-content"><main className="oxa-main">{children}</main></Content>
|
|
237
61
|
</Layout>
|
|
238
62
|
</Layout>
|
|
239
63
|
);
|
|
240
64
|
}
|
|
65
|
+
|
|
66
|
+
export function EmptyApplicationPage() {
|
|
67
|
+
return <Shell><Empty description="还没有声明数据资源;请在 platform/data 中添加 DataResource" /></Shell>;
|
|
68
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ArrowRightOutlined,
|
|
2
3
|
DeleteOutlined,
|
|
3
4
|
EditOutlined,
|
|
4
5
|
EyeOutlined,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
import { useCreate, useDelete, useList, useOne, useUpdate } from '@refinedev/core';
|
|
9
10
|
import {
|
|
10
11
|
App,
|
|
12
|
+
Alert,
|
|
11
13
|
Button,
|
|
12
14
|
Card,
|
|
13
15
|
Descriptions,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
type SurfaceField,
|
|
46
48
|
} from './SurfaceFields';
|
|
47
49
|
import { createNativeResourceClient, type GenericResourceQuery } from '../../platform-client';
|
|
50
|
+
import { ResolvedValueText } from '../../AuthoritativeSelector';
|
|
48
51
|
import { useRuntime } from '../../runtime';
|
|
49
52
|
|
|
50
53
|
type ResourceRecord = Record<string, unknown> & {
|
|
@@ -98,13 +101,150 @@ function normalizeFormValues(values: Record<string, unknown>, surface: DataResou
|
|
|
98
101
|
const widget = surface.fields[key]?.widget;
|
|
99
102
|
return widget === 'date' || widget === 'datetime'
|
|
100
103
|
? [key, value && typeof value === 'object' && 'format' in value
|
|
101
|
-
? (
|
|
104
|
+
? (widget === 'datetime'
|
|
105
|
+
? dayjs(value as never).toISOString()
|
|
106
|
+
: (value as { format: (pattern: string) => string }).format('YYYY-MM-DD'))
|
|
102
107
|
: value]
|
|
103
108
|
: [key, value];
|
|
104
109
|
})
|
|
105
110
|
);
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
function fieldWritable(
|
|
114
|
+
field: SurfaceField,
|
|
115
|
+
mode: 'create' | 'edit',
|
|
116
|
+
hasCapability: (code: string) => boolean,
|
|
117
|
+
isAppSuperAdmin: boolean
|
|
118
|
+
) {
|
|
119
|
+
const capability = mode === 'create' ? field.createCapability : field.updateCapability;
|
|
120
|
+
if (isAppSuperAdmin) return true;
|
|
121
|
+
if (capability) return hasCapability(capability);
|
|
122
|
+
return mode === 'create';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function auditOperationLabel(operation: DataAuditEntry['operation']) {
|
|
126
|
+
return operation === 'created'
|
|
127
|
+
? '创建'
|
|
128
|
+
: operation === 'deleted'
|
|
129
|
+
? '删除'
|
|
130
|
+
: '更新';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function auditValue(field: SurfaceField, value: unknown) {
|
|
134
|
+
if (value === undefined || value === null || value === '') {
|
|
135
|
+
return <Typography.Text type="secondary">-</Typography.Text>;
|
|
136
|
+
}
|
|
137
|
+
return <SurfaceFieldValue field={field} value={value} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function auditChangeFields(entry: DataAuditEntry, surface: DataResourceSurface) {
|
|
141
|
+
const fields = fieldsBySection(surface).flatMap(group => group.fields);
|
|
142
|
+
const source = entry.changes || entry.record || entry.before || {};
|
|
143
|
+
return fields.filter(field => Object.prototype.hasOwnProperty.call(source, field.key));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function exportCellValue(field: SurfaceField, value: unknown) {
|
|
147
|
+
if (value === undefined || value === null || value === '') return '';
|
|
148
|
+
if (field.widget === 'boolean') return value ? '是' : '否';
|
|
149
|
+
if (field.options && !Array.isArray(value)) {
|
|
150
|
+
return field.options.find(option => option.value === value)?.label || String(value);
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
return value.map(item => field.options?.find(option => option.value === item)?.label || String(item)).join('、');
|
|
154
|
+
}
|
|
155
|
+
if (field.widget === 'date' || field.widget === 'datetime') {
|
|
156
|
+
const parsed = new Date(String(value));
|
|
157
|
+
if (!Number.isNaN(parsed.getTime())) return parsed.toLocaleString('zh-CN', { hour12: false });
|
|
158
|
+
}
|
|
159
|
+
if (typeof value === 'object' && value && 'id' in value) return String((value as { id: unknown }).id);
|
|
160
|
+
return String(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function csvCell(value: string) {
|
|
164
|
+
return /[\r\n,"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function exportResourceRows(
|
|
168
|
+
code: string,
|
|
169
|
+
name: string,
|
|
170
|
+
surface: DataResourceSurface,
|
|
171
|
+
query: Partial<GenericResourceQuery>,
|
|
172
|
+
sort: { field: string; order: 'asc' | 'desc' },
|
|
173
|
+
) {
|
|
174
|
+
const client = createNativeResourceClient(code, surface);
|
|
175
|
+
const fields = listSurfaceFields(surface.fields);
|
|
176
|
+
const rows: ResourceRecord[] = [];
|
|
177
|
+
const pageSize = 500;
|
|
178
|
+
let page = 1;
|
|
179
|
+
let total = 0;
|
|
180
|
+
do {
|
|
181
|
+
const result = await client.list({
|
|
182
|
+
page,
|
|
183
|
+
pageSize,
|
|
184
|
+
keyword: query.keyword,
|
|
185
|
+
searchField: query.searchField,
|
|
186
|
+
filters: query.filters,
|
|
187
|
+
sort,
|
|
188
|
+
});
|
|
189
|
+
rows.push(...(result.rows as ResourceRecord[]));
|
|
190
|
+
total = result.total;
|
|
191
|
+
page += 1;
|
|
192
|
+
} while (rows.length < total && rows.length < 10000 && page <= 20);
|
|
193
|
+
const header = fields.map(field => csvCell(field.label)).join(',');
|
|
194
|
+
const body = rows.map(row => fields.map(field => csvCell(exportCellValue(field, row[field.key]))).join(','));
|
|
195
|
+
const blob = new Blob([`\uFEFF${[header, ...body].join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
|
|
196
|
+
const url = URL.createObjectURL(blob);
|
|
197
|
+
const anchor = document.createElement('a');
|
|
198
|
+
anchor.href = url;
|
|
199
|
+
anchor.download = `${name}-${dayjs().format('YYYYMMDD-HHmmss')}.csv`;
|
|
200
|
+
anchor.click();
|
|
201
|
+
URL.revokeObjectURL(url);
|
|
202
|
+
return { count: rows.length, truncated: rows.length < total };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function AuditEntry({ entry, surface }: { entry: DataAuditEntry; surface: DataResourceSurface }) {
|
|
206
|
+
const fields = auditChangeFields(entry, surface);
|
|
207
|
+
const changes = entry.changes || entry.record || entry.before || {};
|
|
208
|
+
return (
|
|
209
|
+
<div className="oxa-audit-entry">
|
|
210
|
+
<div className="oxa-audit-heading">
|
|
211
|
+
<strong>{auditOperationLabel(entry.operation)}</strong>
|
|
212
|
+
<Typography.Text type="secondary">版本 {entry.revision}</Typography.Text>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="oxa-audit-meta">
|
|
215
|
+
<span>操作人</span>
|
|
216
|
+
{entry.actor.principalType === 'application' ? (
|
|
217
|
+
<Typography.Text>应用服务{entry.actor.oauthClientId ? `(${entry.actor.oauthClientId})` : ''}</Typography.Text>
|
|
218
|
+
) : (
|
|
219
|
+
<ResolvedValueText source="user" value={entry.actor.userId} />
|
|
220
|
+
)}
|
|
221
|
+
<span>时间</span>
|
|
222
|
+
<Typography.Text>{new Date(entry.occurredAt).toLocaleString('zh-CN')}</Typography.Text>
|
|
223
|
+
</div>
|
|
224
|
+
{fields.length ? (
|
|
225
|
+
<div className="oxa-audit-changes">
|
|
226
|
+
{fields.map(field => {
|
|
227
|
+
const before = entry.operation === 'created' ? undefined : entry.before?.[field.key];
|
|
228
|
+
const after = entry.operation === 'deleted' ? undefined : changes[field.key];
|
|
229
|
+
return (
|
|
230
|
+
<div className="oxa-audit-change" key={field.key}>
|
|
231
|
+
<Typography.Text strong>{field.label}</Typography.Text>
|
|
232
|
+
<div className="oxa-audit-change-values">
|
|
233
|
+
{auditValue(field, before)}
|
|
234
|
+
<ArrowRightOutlined />
|
|
235
|
+
{auditValue(field, after)}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
) : (
|
|
242
|
+
<Typography.Text type="secondary">未发生字段变化</Typography.Text>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
108
248
|
function useGeneratedList(definition: GeneratedDefinition) {
|
|
109
249
|
const { code, surface } = definition;
|
|
110
250
|
const defaultSort = surface.list?.defaultSort || {
|
|
@@ -152,10 +292,11 @@ function GeneratedResourceListPage({ definition, variant }: { definition: Genera
|
|
|
152
292
|
function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition }) {
|
|
153
293
|
const navigate = useNavigate();
|
|
154
294
|
const { message } = App.useApp();
|
|
155
|
-
const { hasCapability } = useRuntime();
|
|
295
|
+
const { hasCapability, identity } = useRuntime();
|
|
156
296
|
const state = useGeneratedList(definition);
|
|
157
297
|
const { code, name, surface, capabilities } = definition;
|
|
158
298
|
const fields = listSurfaceFields(surface.fields);
|
|
299
|
+
const [exporting, setExporting] = useState(false);
|
|
159
300
|
const remove = useDelete();
|
|
160
301
|
const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
|
|
161
302
|
const columns = useMemo<TableColumnsType<ResourceRecord>>(() => [
|
|
@@ -185,17 +326,37 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
185
326
|
),
|
|
186
327
|
},
|
|
187
328
|
], [capabilities.delete, capabilities.update, code, fields, hasCapability, message, name, navigate, remove, state.list.query]);
|
|
329
|
+
const handleExport = async () => {
|
|
330
|
+
setExporting(true);
|
|
331
|
+
try {
|
|
332
|
+
const result = await exportResourceRows(code, name, surface, state.query, state.sort);
|
|
333
|
+
message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
message.error(error instanceof Error ? error.message : '导出失败');
|
|
336
|
+
} finally {
|
|
337
|
+
setExporting(false);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
188
340
|
return (
|
|
189
341
|
<ResourceListPage
|
|
190
342
|
createCapability={capabilities.create}
|
|
191
343
|
createPath={`/${code}/new`}
|
|
192
344
|
description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
|
|
193
|
-
onExport={() =>
|
|
345
|
+
onExport={() => void handleExport()}
|
|
194
346
|
readCapability={capabilities.read}
|
|
195
347
|
resource={code}
|
|
196
348
|
surface={surface}
|
|
197
349
|
title={name}
|
|
198
350
|
>
|
|
351
|
+
{state.list.query.isError && (
|
|
352
|
+
<Alert
|
|
353
|
+
action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
|
|
354
|
+
description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
|
|
355
|
+
message="数据读取失败"
|
|
356
|
+
showIcon
|
|
357
|
+
type="error"
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
199
360
|
<div className="oxa-filter-grid">
|
|
200
361
|
<Select allowClear options={(surface.list?.searchableFields || []).map(key => ({ label: fieldFor(surface, key).label, value: key }))} placeholder="搜索字段" value={state.draft.searchField} onChange={value => state.setDraft(item => ({ ...item, searchField: value }))} />
|
|
201
362
|
<Input allowClear prefix={<SearchOutlined />} placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} />
|
|
@@ -204,14 +365,14 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
204
365
|
return <SurfaceFilterControl key={key} field={field} value={state.draft.filters?.[key]} onChange={value => state.setDraft(item => ({ ...item, filters: { ...item.filters, [key]: value } }))} />;
|
|
205
366
|
})}
|
|
206
367
|
<Space>
|
|
207
|
-
<Button type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
|
|
368
|
+
<Button loading={state.list.query.isFetching} type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
|
|
208
369
|
<Button onClick={() => { state.setDraft({}); state.setQuery({}); state.setPage(1); }}>重置</Button>
|
|
209
370
|
</Space>
|
|
210
371
|
</div>
|
|
211
372
|
<Table<ResourceRecord>
|
|
212
373
|
columns={columns}
|
|
213
374
|
dataSource={rows}
|
|
214
|
-
loading={state.list.query.
|
|
375
|
+
loading={state.list.query.isFetching}
|
|
215
376
|
locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
|
|
216
377
|
rowKey="id"
|
|
217
378
|
onChange={(_, __, sorter) => { const item = Array.isArray(sorter) ? sorter[0] : sorter; if (item?.field && item.order) state.setSort({ field: String(item.field), order: item.order === 'ascend' ? 'asc' : 'desc' }); }}
|
|
@@ -224,17 +385,39 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
224
385
|
function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }) {
|
|
225
386
|
const navigate = useNavigate();
|
|
226
387
|
const { hasCapability } = useRuntime();
|
|
388
|
+
const { message } = App.useApp();
|
|
227
389
|
const { code, name, surface, capabilities } = definition;
|
|
228
390
|
const state = useGeneratedList(definition);
|
|
391
|
+
const [exporting, setExporting] = useState(false);
|
|
229
392
|
const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
|
|
230
393
|
const fields = listSurfaceFields(surface.fields).slice(0, 4);
|
|
394
|
+
const handleExport = async () => {
|
|
395
|
+
setExporting(true);
|
|
396
|
+
try {
|
|
397
|
+
const result = await exportResourceRows(code, name, surface, state.query, state.sort);
|
|
398
|
+
message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
message.error(error instanceof Error ? error.message : '导出失败');
|
|
401
|
+
} finally {
|
|
402
|
+
setExporting(false);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
231
405
|
return (
|
|
232
|
-
<MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
|
|
406
|
+
<MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} onExport={() => void handleExport()} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
|
|
407
|
+
{state.list.query.isError && (
|
|
408
|
+
<Alert
|
|
409
|
+
action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
|
|
410
|
+
description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
|
|
411
|
+
message="数据读取失败"
|
|
412
|
+
showIcon
|
|
413
|
+
type="error"
|
|
414
|
+
/>
|
|
415
|
+
)}
|
|
233
416
|
<div className="oxa-mobile-filter">
|
|
234
417
|
<Input allowClear placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} onPressEnter={() => { state.setPage(1); state.setQuery(state.draft); }} />
|
|
235
|
-
<Button icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
|
|
418
|
+
<Button loading={state.list.query.isFetching} icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
|
|
236
419
|
</div>
|
|
237
|
-
{state.list.query.
|
|
420
|
+
{state.list.query.isFetching ? <div className="oxa-page-loading"><Spin /></div> : rows.length ? <div className="oxa-mobile-record-list">{rows.map(record => <Card className="oxa-mobile-record-card" key={record.id} size="small"><div className="oxa-mobile-record-heading"><a onClick={() => navigate(`/${code}/${record.id}`)}>{String(record[fields[0]?.key || 'id'] || '-')}</a></div><div className="oxa-mobile-record-fields">{fields.slice(1).map(field => <div key={field.key}><Typography.Text type="secondary">{field.label}</Typography.Text><div><SurfaceFieldValue field={field} value={record[field.key]} /></div></div>)}</div><Space><Button onClick={() => navigate(`/${code}/${record.id}`)} size="small">查看</Button>{hasCapability(capabilities.update) && <Button onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small">编辑</Button>}</Space></Card>)}</div> : <Card><Empty description={`暂无${name}`} /></Card>}
|
|
238
421
|
<div className="oxa-mobile-pagination"><Button disabled={state.page <= 1} onClick={() => state.setPage(item => item - 1)}>上一页</Button><Typography.Text>第 {state.page} 页</Typography.Text><Button disabled={rows.length < (surface.list?.defaultPageSize || 20)} onClick={() => state.setPage(item => item + 1)}>下一页</Button></div>
|
|
239
422
|
</MobileResourceListPage>
|
|
240
423
|
);
|
|
@@ -244,7 +427,7 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
|
|
|
244
427
|
const { id = '' } = useParams();
|
|
245
428
|
const navigate = useNavigate();
|
|
246
429
|
const { message } = App.useApp();
|
|
247
|
-
const { hasCapability } = useRuntime();
|
|
430
|
+
const { hasCapability, identity } = useRuntime();
|
|
248
431
|
const [form] = Form.useForm();
|
|
249
432
|
const create = useCreate<ResourceRecord>();
|
|
250
433
|
const update = useUpdate<ResourceRecord>();
|
|
@@ -255,17 +438,25 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
|
|
|
255
438
|
const listPath = `/${code}`;
|
|
256
439
|
const detailPath = `/${code}/${id}`;
|
|
257
440
|
const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
|
|
441
|
+
const FieldControl = variant === 'mobile' ? MobileSurfaceFieldControl : SurfaceFieldControl;
|
|
258
442
|
if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update)) return <Result status="403" title="当前平台用户无此操作权限" />;
|
|
259
443
|
const submit = async (values: Record<string, unknown>) => {
|
|
260
|
-
const next = normalizeFormValues(
|
|
444
|
+
const next = normalizeFormValues(
|
|
445
|
+
Object.fromEntries(
|
|
446
|
+
Object.entries(values).filter(([key]) =>
|
|
447
|
+
fieldWritable(surface.fields[key] ? { key, ...surface.fields[key] } : { key, label: key, widget: 'text' }, mode, hasCapability, identity.isAppSuperAdmin)
|
|
448
|
+
)
|
|
449
|
+
),
|
|
450
|
+
surface
|
|
451
|
+
);
|
|
261
452
|
if (mode === 'create') { await create.mutateAsync({ resource: code, values: next }); message.success(`${name}已创建`); navigate(listPath); }
|
|
262
453
|
else { const result = await update.mutateAsync({ resource: code, id, values: next, meta: { expectedRevision: record?.revision } }); message.success('修改已保存'); navigate(`/${code}/${result.data.id}`); }
|
|
263
454
|
};
|
|
264
455
|
return (
|
|
265
|
-
<Page backLabel={mode === 'edit' ? `返回${name}详情` : `返回${name}`} backPath={mode === 'edit' ? detailPath : listPath} capability={mode === 'create' ? capabilities.create : capabilities.update} detailPath={record ? detailPath : undefined} loading={mode === 'edit' && query.query.
|
|
456
|
+
<Page backLabel={mode === 'edit' ? `返回${name}详情` : `返回${name}`} backPath={mode === 'edit' ? detailPath : listPath} capability={mode === 'create' ? capabilities.create : capabilities.update} detailPath={record ? detailPath : undefined} loading={mode === 'edit' && query.query.isFetching} mode={mode} notFound={mode === 'edit' && !record} recordSummary={record ? `${name} · 当前版本 ${record.revision}` : undefined} resource={code} surface={surface} title={name}>
|
|
266
457
|
<Form className={variant === 'mobile' ? 'oxa-mobile-form' : 'oxa-form-full'} form={form} initialValues={undefined} layout="vertical" onFinish={values => void submit(values as Record<string, unknown>)}>
|
|
267
|
-
<div className="oxa-sections">{fieldsBySection(surface).map(group => <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}><div className="oxa-grid">{group.fields.map(field =>
|
|
268
|
-
<div className="oxa-actions"><Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button><Button htmlType="submit" type="primary">{mode === 'create' ? '保存' : '保存修改'}</Button></div>
|
|
458
|
+
<div className="oxa-sections">{fieldsBySection(surface).map(group => <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}><div className="oxa-grid">{group.fields.map(field => <FieldControl key={field.key} disabled={!fieldWritable(field, mode, hasCapability, identity.isAppSuperAdmin)} field={field} operation={mode === 'create' ? 'create' : 'update'} recordId={record?.id} renderers={{ upload: (current, file, recordId) => createNativeResourceClient(code, surface).upload(current.key, file, recordId) }} />)}</div></Card>)}</div>
|
|
459
|
+
<div className="oxa-actions"><Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button><Button htmlType="submit" loading={create.mutation.isPending || update.mutation.isPending} type="primary">{mode === 'create' ? '保存' : '保存修改'}</Button></div>
|
|
269
460
|
</Form>
|
|
270
461
|
</Page>
|
|
271
462
|
);
|
|
@@ -277,9 +468,31 @@ function GeneratedResourceDetailPage({ definition, variant }: { definition: Gene
|
|
|
277
468
|
const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { retry: false } });
|
|
278
469
|
const record = query.result;
|
|
279
470
|
const [auditEntries, setAuditEntries] = useState<DataAuditEntry[]>([]);
|
|
280
|
-
|
|
281
|
-
const
|
|
471
|
+
const [auditLoading, setAuditLoading] = useState(false);
|
|
472
|
+
const [auditError, setAuditError] = useState('');
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (!record) return;
|
|
475
|
+
let active = true;
|
|
476
|
+
setAuditLoading(true);
|
|
477
|
+
setAuditError('');
|
|
478
|
+
void createNativeResourceClient(definition.code, definition.surface).audit(record.id)
|
|
479
|
+
.then(page => {
|
|
480
|
+
if (active) setAuditEntries(page.items || []);
|
|
481
|
+
})
|
|
482
|
+
.catch(reason => {
|
|
483
|
+
if (!active) return;
|
|
484
|
+
setAuditEntries([]);
|
|
485
|
+
setAuditError(reason instanceof Error ? reason.message : String(reason));
|
|
486
|
+
})
|
|
487
|
+
.finally(() => {
|
|
488
|
+
if (active) setAuditLoading(false);
|
|
489
|
+
});
|
|
490
|
+
return () => {
|
|
491
|
+
active = false;
|
|
492
|
+
};
|
|
493
|
+
}, [definition.code, definition.surface, record?.id]);
|
|
494
|
+
const content = record ? <div className="oxa-detail-layout"> <div className="oxa-detail-main">{fieldsBySection(definition.surface).map(group => <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}><Descriptions column={{ xs: 1, sm: 2, lg: 3 }} items={group.fields.map(field => ({ key: field.key, label: field.label, children: <SurfaceFieldValue field={field} value={record[field.key]} /> }))} /></Card>)}</div><aside className="oxa-detail-aside"><Card className="oxa-section-card" id={`${definition.code}-audit`} loading={auditLoading} title="最近变更">{auditError ? <Alert message="变更记录读取失败" description={auditError} type="error" showIcon /> : auditEntries.length ? auditEntries.slice(0, 5).map(entry => <AuditEntry entry={entry} key={entry.id} surface={definition.surface} />) : <Typography.Text type="secondary">暂无变更记录</Typography.Text>}</Card></aside></div> : null;
|
|
282
495
|
const hero = record ? <div><Typography.Title level={variant === 'mobile' ? 3 : 2}>{definition.name}详情</Typography.Title><Typography.Text type="secondary">{record.id} · 版本 {record.revision}</Typography.Text></div> : null;
|
|
283
|
-
const props = { backLabel: `返回${definition.name}`, backPath: `/${definition.code}`, editCapability: definition.capabilities.update, editPath: `/${definition.code}/${id}/edit`, error: query.query.isError || (!query.query.
|
|
496
|
+
const props = { backLabel: `返回${definition.name}`, backPath: `/${definition.code}`, editCapability: definition.capabilities.update, editPath: `/${definition.code}/${id}/edit`, error: query.query.isError || (!query.query.isFetching && !record) ? query.query.error?.message || '记录不存在' : undefined, hero, loading: query.query.isFetching, meta: record ? <><span>版本 {record.revision}</span><span>最近更新 {record.updated_at ? new Date(record.updated_at).toLocaleString('zh-CN') : '-'}</span></> : null, readCapability: definition.capabilities.read, resource: definition.code, surface: definition.surface, title: definition.name };
|
|
284
497
|
return variant === 'mobile' ? <MobileResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</MobileResourceDetailPage> : <ResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</ResourceDetailPage>;
|
|
285
498
|
}
|