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.
Files changed (39) hide show
  1. package/dist/create-workspace.d.ts.map +1 -1
  2. package/dist/create-workspace.js +1 -0
  3. package/dist/create-workspace.js.map +1 -1
  4. package/package.json +4 -4
  5. package/template/AGENTS.md +1 -1
  6. package/template/README.md +7 -7
  7. package/template/apps/server/Dockerfile +2 -1
  8. package/template/apps/server/package.json +1 -1
  9. package/template/apps/server/src/app.module.ts +1 -3
  10. package/template/apps/server/test/smoke.test.ts +6 -23
  11. package/template/apps/web/e2e/resources.spec.ts +20 -0
  12. package/template/apps/web/index.html +1 -1
  13. package/template/apps/web/package.json +1 -1
  14. package/template/apps/web/src/AuthoritativeSelector.tsx +298 -41
  15. package/template/apps/web/src/Shell.tsx +39 -211
  16. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
  17. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +229 -16
  18. package/template/apps/web/src/components/resource/SurfaceFields.tsx +142 -2
  19. package/template/apps/web/src/data-provider.ts +23 -102
  20. package/template/apps/web/src/main.tsx +35 -83
  21. package/template/apps/web/src/platform-client.ts +112 -424
  22. package/template/apps/web/src/runtime-meta.ts +1 -1
  23. package/template/apps/web/src/styles.css +285 -3
  24. package/template/apps/web/test/contracts.test.ts +43 -769
  25. package/template/openxiangda.config.ts +14 -81
  26. package/template/package.json +3 -3
  27. package/template/packages/contracts/src/generated.ts +7 -804
  28. package/template/apps/server/src/instrument-context.controller.ts +0 -25
  29. package/template/apps/web/e2e/instruments.spec.ts +0 -1338
  30. package/template/apps/web/src/CollegePage.tsx +0 -181
  31. package/template/apps/web/src/InstrumentDetailPage.tsx +0 -176
  32. package/template/apps/web/src/InstrumentForm.tsx +0 -380
  33. package/template/apps/web/src/InstrumentFormPage.tsx +0 -82
  34. package/template/apps/web/src/InstrumentListPage.tsx +0 -457
  35. package/template/apps/web/src/fields.ts +0 -18
  36. package/template/apps/web/src/instrument.ts +0 -56
  37. package/template/platform/data/colleges.ts +0 -21
  38. package/template/platform/data/instrument-surface.ts +0 -125
  39. package/template/platform/data/instruments.ts +0 -83
@@ -1,69 +1,18 @@
1
- import {
2
- ApartmentOutlined,
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
- type PageMeta = { title: string; breadcrumbs: string[]; selectedKey: string };
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 meta = pageMeta(location.pathname);
76
-
24
+ const definition = currentDefinition(location.pathname);
77
25
  useEffect(() => {
78
26
  let active = true;
79
- void resolveDirectory('user', [identity.userId])
80
- .then(page => {
81
- const entry = page.items[0];
82
- if (active && entry) {
83
- setUser({
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
- const menuItems = useMemo<MenuProps['items']>(
98
- () => [
99
- {
100
- key: 'overview',
101
- icon: <AppstoreOutlined />,
102
- label: '总览',
103
- children: [
104
- { key: 'workbench', icon: <HomeOutlined />, label: '工作台' },
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-sider"
160
- breakpoint="lg"
161
- collapsed={collapsed}
162
- collapsedWidth={76}
163
- collapsible
164
- onBreakpoint={setCollapsed}
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
- <Breadcrumb items={meta.breadcrumbs.map(title => ({ title }))} />
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
+ }
@@ -78,7 +78,7 @@ function openIsolatedWindow(url: string) {
78
78
 
79
79
  export function AttachmentFileList({
80
80
  files,
81
- resourceCode = 'instruments',
81
+ resourceCode = 'resources',
82
82
  removable = false,
83
83
  onRemove,
84
84
  }: {
@@ -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
- ? (value as { format: (pattern: string) => string }).format(widget === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
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={() => message.info('导出将使用当前 Data API 查询条件')}
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.isLoading}
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.isLoading ? <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>}
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(values, surface);
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.isLoading} mode={mode} notFound={mode === 'edit' && !record} recordSummary={record ? `${name} · 当前版本 ${record.revision}` : undefined} resource={code} surface={surface} title={name}>
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 => { const capability = mode === 'create' ? field.createCapability : field.updateCapability; return <SurfaceFieldControl key={field.key} disabled={Boolean(capability) && !hasCapability(capability!)} 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>
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
- useEffect(() => { if (!record) return; void createNativeResourceClient(definition.code, definition.surface).audit(record.id).then(page => setAuditEntries(page.items || []), () => setAuditEntries([])); }, [definition, record]);
281
- 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`} title="最近变更">{auditEntries.length ? auditEntries.slice(0, 5).map(entry => <div className="oxa-audit-entry" key={`${entry.occurredAt}-${entry.operation}`}><strong>{entry.operation}</strong><span>{new Date(entry.occurredAt).toLocaleString('zh-CN')}</span></div>) : <Typography.Text type="secondary">暂无变更记录</Typography.Text>}</Card></aside></div> : null;
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.isLoading && !record) ? query.query.error?.message || '记录不存在' : undefined, hero, loading: query.query.isLoading, 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 };
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
  }