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.
@@ -1,10 +1,16 @@
1
1
  import {
2
2
  ArrowRightOutlined,
3
+ ColumnHeightOutlined,
3
4
  DeleteOutlined,
4
5
  EditOutlined,
5
6
  EyeOutlined,
7
+ ExportOutlined,
6
8
  PlusOutlined,
9
+ ReloadOutlined,
7
10
  SearchOutlined,
11
+ SettingOutlined,
12
+ UpOutlined,
13
+ DownOutlined,
8
14
  } from '@ant-design/icons';
9
15
  import { useCreate, useDelete, useList, useOne, useUpdate } from '@refinedev/core';
10
16
  import {
@@ -12,7 +18,9 @@ import {
12
18
  Alert,
13
19
  Button,
14
20
  Card,
21
+ Collapse,
15
22
  Descriptions,
23
+ Dropdown,
16
24
  Empty,
17
25
  Form,
18
26
  Input,
@@ -22,7 +30,9 @@ import {
22
30
  Space,
23
31
  Spin,
24
32
  Table,
33
+ Tooltip,
25
34
  Typography,
35
+ type MenuProps,
26
36
  type TableColumnsType,
27
37
  } from 'antd';
28
38
  import dayjs from 'dayjs';
@@ -49,6 +59,7 @@ import {
49
59
  import { createNativeResourceClient, type GenericResourceQuery } from '../../platform-client';
50
60
  import { ResolvedValueText } from '../../AuthoritativeSelector';
51
61
  import { useRuntime } from '../../runtime';
62
+ import { ResourceBatchActions, ResourceImportButton } from './ResourceBatchActions';
52
63
 
53
64
  type ResourceRecord = Record<string, unknown> & {
54
65
  id: string;
@@ -59,7 +70,12 @@ type ResourceRecord = Record<string, unknown> & {
59
70
  type GeneratedDefinition = {
60
71
  code: string;
61
72
  name: string;
62
- capabilities: { read: string; create: string; update: string; delete: string };
73
+ capabilities: {
74
+ read: string;
75
+ create: string;
76
+ update: string;
77
+ delete: string;
78
+ };
63
79
  surface: DataResourceSurface;
64
80
  };
65
81
 
@@ -81,16 +97,17 @@ function fieldsBySection(surface: DataResourceSurface) {
81
97
  const section = field.section || 'default';
82
98
  groups.set(section, [...(groups.get(section) || []), { key, ...field }]);
83
99
  }
84
- return [...groups.entries()].map(([section, fields]) => ({ section, fields }));
100
+ return [...groups.entries()].map(([section, fields]) => ({
101
+ section,
102
+ fields,
103
+ }));
85
104
  }
86
105
 
87
106
  function normalizeRecordForForm(record: ResourceRecord, surface: DataResourceSurface) {
88
107
  return Object.fromEntries(
89
108
  Object.entries(record).map(([key, value]) => {
90
109
  const widget = surface.fields[key]?.widget;
91
- return widget === 'date' || widget === 'datetime'
92
- ? [key, value ? dayjs(String(value)) : undefined]
93
- : [key, value];
110
+ return widget === 'date' || widget === 'datetime' ? [key, value ? dayjs(String(value)) : undefined] : [key, value];
94
111
  })
95
112
  );
96
113
  }
@@ -100,22 +117,20 @@ function normalizeFormValues(values: Record<string, unknown>, surface: DataResou
100
117
  Object.entries(values).map(([key, value]) => {
101
118
  const widget = surface.fields[key]?.widget;
102
119
  return widget === 'date' || widget === 'datetime'
103
- ? [key, value && typeof value === 'object' && 'format' in value
104
- ? (widget === 'datetime'
105
- ? dayjs(value as never).toISOString()
106
- : (value as { format: (pattern: string) => string }).format('YYYY-MM-DD'))
107
- : value]
120
+ ? [
121
+ key,
122
+ value && typeof value === 'object' && 'format' in value
123
+ ? widget === 'datetime'
124
+ ? dayjs(value as never).toISOString()
125
+ : (value as { format: (pattern: string) => string }).format('YYYY-MM-DD')
126
+ : value,
127
+ ]
108
128
  : [key, value];
109
129
  })
110
130
  );
111
131
  }
112
132
 
113
- function fieldWritable(
114
- field: SurfaceField,
115
- mode: 'create' | 'edit',
116
- hasCapability: (code: string) => boolean,
117
- isAppSuperAdmin: boolean
118
- ) {
133
+ function fieldWritable(field: SurfaceField, mode: 'create' | 'edit', hasCapability: (code: string) => boolean, isAppSuperAdmin: boolean) {
119
134
  const capability = mode === 'create' ? field.createCapability : field.updateCapability;
120
135
  if (isAppSuperAdmin) return true;
121
136
  if (capability) return hasCapability(capability);
@@ -123,11 +138,7 @@ function fieldWritable(
123
138
  }
124
139
 
125
140
  function auditOperationLabel(operation: DataAuditEntry['operation']) {
126
- return operation === 'created'
127
- ? '创建'
128
- : operation === 'deleted'
129
- ? '删除'
130
- : '更新';
141
+ return operation === 'created' ? '创建' : operation === 'deleted' ? '删除' : '更新';
131
142
  }
132
143
 
133
144
  function auditValue(field: SurfaceField, value: unknown) {
@@ -138,19 +149,19 @@ function auditValue(field: SurfaceField, value: unknown) {
138
149
  }
139
150
 
140
151
  function auditChangeFields(entry: DataAuditEntry, surface: DataResourceSurface) {
141
- const fields = fieldsBySection(surface).flatMap(group => group.fields);
152
+ const fields = fieldsBySection(surface).flatMap((group) => group.fields);
142
153
  const source = entry.changes || entry.record || entry.before || {};
143
- return fields.filter(field => Object.prototype.hasOwnProperty.call(source, field.key));
154
+ return fields.filter((field) => Object.prototype.hasOwnProperty.call(source, field.key));
144
155
  }
145
156
 
146
157
  function exportCellValue(field: SurfaceField, value: unknown) {
147
158
  if (value === undefined || value === null || value === '') return '';
148
159
  if (field.widget === 'boolean') return value ? '是' : '否';
149
160
  if (field.options && !Array.isArray(value)) {
150
- return field.options.find(option => option.value === value)?.label || String(value);
161
+ return field.options.find((option) => option.value === value)?.label || String(value);
151
162
  }
152
163
  if (Array.isArray(value)) {
153
- return value.map(item => field.options?.find(option => option.value === item)?.label || String(item)).join('、');
164
+ return value.map((item) => field.options?.find((option) => option.value === item)?.label || String(item)).join('、');
154
165
  }
155
166
  if (field.widget === 'date' || field.widget === 'datetime') {
156
167
  const parsed = new Date(String(value));
@@ -169,7 +180,7 @@ async function exportResourceRows(
169
180
  name: string,
170
181
  surface: DataResourceSurface,
171
182
  query: Partial<GenericResourceQuery>,
172
- sort: { field: string; order: 'asc' | 'desc' },
183
+ sort: { field: string; order: 'asc' | 'desc' }
173
184
  ) {
174
185
  const client = createNativeResourceClient(code, surface);
175
186
  const fields = listSurfaceFields(surface.fields);
@@ -190,9 +201,11 @@ async function exportResourceRows(
190
201
  total = result.total;
191
202
  page += 1;
192
203
  } 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' });
204
+ const header = fields.map((field) => csvCell(field.label)).join(',');
205
+ const body = rows.map((row) => fields.map((field) => csvCell(exportCellValue(field, row[field.key]))).join(','));
206
+ const blob = new Blob([`\uFEFF${[header, ...body].join('\r\n')}`], {
207
+ type: 'text/csv;charset=utf-8',
208
+ });
196
209
  const url = URL.createObjectURL(blob);
197
210
  const anchor = document.createElement('a');
198
211
  anchor.href = url;
@@ -214,7 +227,10 @@ function AuditEntry({ entry, surface }: { entry: DataAuditEntry; surface: DataRe
214
227
  <div className="oxa-audit-meta">
215
228
  <span>操作人</span>
216
229
  {entry.actor.principalType === 'application' ? (
217
- <Typography.Text>应用服务{entry.actor.oauthClientId ? `(${entry.actor.oauthClientId})` : ''}</Typography.Text>
230
+ <Typography.Text>
231
+ 应用服务
232
+ {entry.actor.oauthClientId ? `(${entry.actor.oauthClientId})` : ''}
233
+ </Typography.Text>
218
234
  ) : (
219
235
  <ResolvedValueText source="user" value={entry.actor.userId} />
220
236
  )}
@@ -223,7 +239,7 @@ function AuditEntry({ entry, surface }: { entry: DataAuditEntry; surface: DataRe
223
239
  </div>
224
240
  {fields.length ? (
225
241
  <div className="oxa-audit-changes">
226
- {fields.map(field => {
242
+ {fields.map((field) => {
227
243
  const before = entry.operation === 'created' ? undefined : entry.before?.[field.key];
228
244
  const after = entry.operation === 'deleted' ? undefined : changes[field.key];
229
245
  return (
@@ -254,13 +270,19 @@ function useGeneratedList(definition: GeneratedDefinition) {
254
270
  const [pageSize, setPageSize] = useState(defaultPageSize);
255
271
  const [draft, setDraft] = useState<Partial<GenericResourceQuery>>({});
256
272
  const [query, setQuery] = useState<Partial<GenericResourceQuery>>({});
257
- const [sort, setSort] = useState({ field: defaultSortField, order: defaultSortOrder as 'asc' | 'desc' });
273
+ const [sort, setSort] = useState({
274
+ field: defaultSortField,
275
+ order: defaultSortOrder as 'asc' | 'desc',
276
+ });
258
277
  useEffect(() => {
259
278
  setPage(1);
260
279
  setPageSize(defaultPageSize);
261
280
  setDraft({});
262
281
  setQuery({});
263
- setSort({ field: defaultSortField, order: defaultSortOrder as 'asc' | 'desc' });
282
+ setSort({
283
+ field: defaultSortField,
284
+ order: defaultSortOrder as 'asc' | 'desc',
285
+ });
264
286
  }, [code, defaultPageSize, defaultSortField, defaultSortOrder]);
265
287
  const list = useList<ResourceRecord>({
266
288
  resource: code,
@@ -268,7 +290,19 @@ function useGeneratedList(definition: GeneratedDefinition) {
268
290
  sorters: [sort],
269
291
  meta: { query },
270
292
  });
271
- return { draft, list, page, pageSize, query, setDraft, setPage, setPageSize, setQuery, setSort, sort };
293
+ return {
294
+ draft,
295
+ list,
296
+ page,
297
+ pageSize,
298
+ query,
299
+ setDraft,
300
+ setPage,
301
+ setPageSize,
302
+ setQuery,
303
+ setSort,
304
+ sort,
305
+ };
272
306
  }
273
307
 
274
308
  export function GeneratedResourcePage({
@@ -291,9 +325,7 @@ export function GeneratedResourcePage({
291
325
  }
292
326
 
293
327
  function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
294
- return variant === 'mobile'
295
- ? <GeneratedMobileList definition={definition} />
296
- : <GeneratedDesktopList definition={definition} />;
328
+ return variant === 'mobile' ? <GeneratedMobileList definition={definition} /> : <GeneratedDesktopList definition={definition} />;
297
329
  }
298
330
 
299
331
  function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition }) {
@@ -304,35 +336,114 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
304
336
  const { code, name, surface, capabilities } = definition;
305
337
  const fields = listSurfaceFields(surface.fields);
306
338
  const [exporting, setExporting] = useState(false);
339
+ const [filtersExpanded, setFiltersExpanded] = useState(false);
340
+ const [density, setDensity] = useState<'small' | 'middle' | 'large'>('middle');
341
+ const [visibleColumnKeys, setVisibleColumnKeys] = useState<string[]>(() => fields.map((field) => field.key));
342
+ const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
307
343
  const remove = useDelete();
308
344
  const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
309
- const columns = useMemo<TableColumnsType<ResourceRecord>>(() => [
310
- ...fields.map(field => ({
311
- title: field.label,
312
- dataIndex: field.key,
313
- key: field.key,
314
- sorter: Boolean(field.sortable),
315
- render: (value: unknown, record: ResourceRecord) => (
316
- <SurfaceFieldValue field={field} value={value} renderers={{ renderValue: ({ value: current }) => field.key === fields[0]?.key ? <a onClick={() => navigate(`/${code}/${record.id}`)}>{String(current || '-')}</a> : undefined }} />
317
- ),
318
- })),
319
- {
320
- title: '更新时间',
321
- dataIndex: 'updated_at',
322
- render: value => value ? new Date(String(value)).toLocaleString('zh-CN') : '-',
323
- },
324
- {
325
- title: '操作',
326
- key: 'actions',
327
- render: (_: unknown, record: ResourceRecord) => (
328
- <Space>
329
- <Button aria-label="查看" icon={<EyeOutlined />} onClick={() => navigate(`/${code}/${record.id}`)} size="small" />
330
- {hasCapability(capabilities.update) && <Button aria-label="编辑" icon={<EditOutlined />} onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small" />}
331
- {hasCapability(capabilities.delete) && <Popconfirm title={`确定删除${name}?`} onConfirm={() => remove.mutate({ resource: code, id: record.id, meta: { expectedRevision: record.revision } }, { onSuccess: () => { message.success('已删除'); void state.list.query.refetch(); } })}><Button aria-label="删除" danger icon={<DeleteOutlined />} size="small" /></Popconfirm>}
332
- </Space>
333
- ),
334
- },
335
- ], [capabilities.delete, capabilities.update, code, fields, hasCapability, message, name, navigate, remove, state.list.query]);
345
+ useEffect(() => {
346
+ setVisibleColumnKeys(fields.map((field) => field.key));
347
+ setFiltersExpanded(false);
348
+ setDensity('middle');
349
+ setSelectedRowKeys([]);
350
+ }, [code]);
351
+ useEffect(() => {
352
+ setSelectedRowKeys([]);
353
+ }, [code, state.page, state.pageSize, state.query, state.sort.field, state.sort.order]);
354
+ const formFields = fieldsBySection(surface).flatMap((group) => group.fields);
355
+ const importWritableFieldCodes = formFields
356
+ .filter((field) => field.widget !== 'readonly' && fieldWritable(field, 'create', hasCapability, identity.isAppSuperAdmin))
357
+ .map((field) => field.key);
358
+ const batchWritableFields = formFields.filter(
359
+ (field) => !['file', 'readonly'].includes(field.widget || '') && fieldWritable(field, 'edit', hasCapability, identity.isAppSuperAdmin)
360
+ );
361
+ const selectedRows = rows.filter((row) => selectedRowKeys.includes(row.id));
362
+ const canBatch = hasCapability(capabilities.update) || hasCapability(capabilities.delete);
363
+ const columns = useMemo<TableColumnsType<ResourceRecord>>(
364
+ () => [
365
+ ...fields
366
+ .filter((field) => visibleColumnKeys.includes(field.key))
367
+ .map((field) => ({
368
+ title: field.label,
369
+ dataIndex: field.key,
370
+ key: field.key,
371
+ sorter: Boolean(field.sortable),
372
+ render: (value: unknown, record: ResourceRecord) => (
373
+ <SurfaceFieldValue
374
+ field={field}
375
+ value={value}
376
+ renderers={{
377
+ renderValue: ({ value: current }) =>
378
+ field.key === fields[0]?.key ? (
379
+ <a onClick={() => navigate(`/${code}/${record.id}`)}>{String(current || '-')}</a>
380
+ ) : undefined,
381
+ }}
382
+ />
383
+ ),
384
+ })),
385
+ {
386
+ title: '更新时间',
387
+ dataIndex: 'updated_at',
388
+ key: 'updated_at',
389
+ render: (value) => (value ? new Date(String(value)).toLocaleString('zh-CN') : '-'),
390
+ },
391
+ {
392
+ title: '操作',
393
+ key: 'actions',
394
+ fixed: 'right' as const,
395
+ render: (_: unknown, record: ResourceRecord) => (
396
+ <Space size={4}>
397
+ <Button aria-label="查看" icon={<EyeOutlined />} onClick={() => navigate(`/${code}/${record.id}`)} size="small" type="text" />
398
+ {hasCapability(capabilities.update) && (
399
+ <Button
400
+ aria-label="编辑"
401
+ icon={<EditOutlined />}
402
+ onClick={() => navigate(`/${code}/${record.id}/edit`)}
403
+ size="small"
404
+ type="text"
405
+ />
406
+ )}
407
+ {hasCapability(capabilities.delete) && (
408
+ <Popconfirm
409
+ onConfirm={() =>
410
+ remove.mutate(
411
+ {
412
+ resource: code,
413
+ id: record.id,
414
+ meta: { expectedRevision: record.revision },
415
+ },
416
+ {
417
+ onSuccess: () => {
418
+ message.success('已删除');
419
+ void state.list.query.refetch();
420
+ },
421
+ }
422
+ )
423
+ }
424
+ title={`确定删除${name}?`}
425
+ >
426
+ <Button aria-label="删除" danger icon={<DeleteOutlined />} size="small" type="text" />
427
+ </Popconfirm>
428
+ )}
429
+ </Space>
430
+ ),
431
+ },
432
+ ],
433
+ [
434
+ capabilities.delete,
435
+ capabilities.update,
436
+ code,
437
+ fields,
438
+ hasCapability,
439
+ message,
440
+ name,
441
+ navigate,
442
+ remove,
443
+ state.list.query,
444
+ visibleColumnKeys,
445
+ ]
446
+ );
336
447
  const handleExport = async () => {
337
448
  setExporting(true);
338
449
  try {
@@ -344,12 +455,33 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
344
455
  setExporting(false);
345
456
  }
346
457
  };
458
+ const filterFields = surface.list?.filterFields || [];
459
+ const hasMoreFilters = filterFields.length > 2;
460
+ const columnMenu: MenuProps = {
461
+ items: fields.map((field) => ({ key: field.key, label: field.label })),
462
+ multiple: true,
463
+ selectable: true,
464
+ selectedKeys: visibleColumnKeys,
465
+ onSelect: ({ selectedKeys }) => setVisibleColumnKeys(selectedKeys.map(String)),
466
+ onDeselect: ({ selectedKeys }) => {
467
+ if (selectedKeys.length) setVisibleColumnKeys(selectedKeys.map(String));
468
+ else message.info('至少保留一列');
469
+ },
470
+ };
471
+ const densityMenu: MenuProps = {
472
+ items: [
473
+ { key: 'large', label: '宽松' },
474
+ { key: 'middle', label: '标准' },
475
+ { key: 'small', label: '紧凑' },
476
+ ],
477
+ selectable: true,
478
+ selectedKeys: [density],
479
+ onClick: ({ key }) => setDensity(key as typeof density),
480
+ };
347
481
  return (
348
482
  <ResourceListPage
349
483
  createCapability={capabilities.create}
350
484
  createPath={`/${code}/new`}
351
- description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
352
- onExport={() => void handleExport()}
353
485
  readCapability={capabilities.read}
354
486
  resource={code}
355
487
  surface={surface}
@@ -357,33 +489,189 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
357
489
  >
358
490
  {state.list.query.isError && (
359
491
  <Alert
360
- action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
492
+ action={
493
+ <Button onClick={() => void state.list.query.refetch()} size="small">
494
+ 重试
495
+ </Button>
496
+ }
361
497
  description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
362
- message="数据读取失败"
363
498
  showIcon
499
+ title="数据读取失败"
364
500
  type="error"
365
501
  />
366
502
  )}
367
- <div className="oxa-filter-grid">
368
- <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 }))} />
369
- <Input allowClear prefix={<SearchOutlined />} placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} />
370
- {(surface.list?.filterFields || []).map(key => {
371
- const field = fieldFor(surface, key);
372
- return <SurfaceFilterControl key={key} field={field} value={state.draft.filters?.[key]} onChange={value => state.setDraft(item => ({ ...item, filters: { ...item.filters, [key]: value } }))} />;
373
- })}
374
- <Space>
375
- <Button loading={state.list.query.isFetching} type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
376
- <Button onClick={() => { state.setDraft({}); state.setQuery({}); state.setPage(1); }}>重置</Button>
503
+ <section aria-label="数据筛选" className="oxa-search-panel">
504
+ <div className="oxa-filter-grid">
505
+ <div className="oxa-filter-item">
506
+ <Select
507
+ allowClear
508
+ options={(surface.list?.searchableFields || []).map((key) => ({
509
+ label: fieldFor(surface, key).label,
510
+ value: key,
511
+ }))}
512
+ placeholder="搜索字段"
513
+ value={state.draft.searchField}
514
+ onChange={(value) => state.setDraft((item) => ({ ...item, searchField: value }))}
515
+ />
516
+ </div>
517
+ <div className="oxa-filter-item">
518
+ <Input
519
+ allowClear
520
+ onChange={(event) =>
521
+ state.setDraft((item) => ({
522
+ ...item,
523
+ keyword: event.target.value,
524
+ }))
525
+ }
526
+ onPressEnter={() => {
527
+ state.setPage(1);
528
+ state.setQuery(state.draft);
529
+ }}
530
+ placeholder="关键词"
531
+ prefix={<SearchOutlined />}
532
+ value={state.draft.keyword}
533
+ />
534
+ </div>
535
+ {filterFields.map((key, index) => {
536
+ const field = fieldFor(surface, key);
537
+ return (
538
+ <div className={`oxa-filter-item${index >= 2 && !filtersExpanded ? ' is-collapsed' : ''}`} key={key}>
539
+ <SurfaceFilterControl
540
+ field={field}
541
+ onChange={(value) =>
542
+ state.setDraft((item) => ({
543
+ ...item,
544
+ filters: { ...item.filters, [key]: value },
545
+ }))
546
+ }
547
+ value={state.draft.filters?.[key]}
548
+ />
549
+ </div>
550
+ );
551
+ })}
552
+ </div>
553
+ <div className="oxa-filter-actions">
554
+ <Button
555
+ icon={<SearchOutlined />}
556
+ loading={state.list.query.isFetching}
557
+ onClick={() => {
558
+ state.setPage(1);
559
+ state.setQuery(state.draft);
560
+ }}
561
+ type="primary"
562
+ >
563
+ 查询
564
+ </Button>
565
+ <Button
566
+ onClick={() => {
567
+ state.setDraft({});
568
+ state.setQuery({});
569
+ state.setPage(1);
570
+ }}
571
+ >
572
+ 重置
573
+ </Button>
574
+ {hasMoreFilters && (
575
+ <Button
576
+ icon={filtersExpanded ? <UpOutlined /> : <DownOutlined />}
577
+ onClick={() => setFiltersExpanded((value) => !value)}
578
+ type="link"
579
+ >
580
+ {filtersExpanded ? '收起筛选' : `更多筛选(${filterFields.length - 2})`}
581
+ </Button>
582
+ )}
583
+ </div>
584
+ </section>
585
+ <div className="oxa-list-toolbar">
586
+ <Space className="oxa-list-toolbar-primary" wrap>
587
+ {hasCapability(capabilities.create) && (
588
+ <Button icon={<PlusOutlined />} onClick={() => navigate(`/${code}/new`)} type="primary">
589
+ 新增{name}
590
+ </Button>
591
+ )}
592
+ {hasCapability(capabilities.create) && (
593
+ <ResourceImportButton
594
+ code={code}
595
+ name={name}
596
+ surface={surface}
597
+ writableFieldCodes={importWritableFieldCodes}
598
+ onCompleted={() => state.list.query.refetch()}
599
+ />
600
+ )}
601
+ <Button icon={<ExportOutlined />} loading={exporting} onClick={() => void handleExport()}>
602
+ 导出
603
+ </Button>
604
+ </Space>
605
+ <Space className="oxa-list-toolbar-secondary" size={4}>
606
+ <Tooltip title="刷新">
607
+ <Button
608
+ aria-label="刷新数据"
609
+ icon={<ReloadOutlined />}
610
+ loading={state.list.query.isFetching}
611
+ onClick={() => void state.list.query.refetch()}
612
+ type="text"
613
+ />
614
+ </Tooltip>
615
+ <Dropdown menu={columnMenu} trigger={['click']}>
616
+ <Button icon={<SettingOutlined />} type="text">
617
+ 列设置
618
+ </Button>
619
+ </Dropdown>
620
+ <Dropdown menu={densityMenu} trigger={['click']}>
621
+ <Button icon={<ColumnHeightOutlined />} type="text">
622
+ 密度
623
+ </Button>
624
+ </Dropdown>
377
625
  </Space>
378
626
  </div>
627
+ <ResourceBatchActions
628
+ canDelete={hasCapability(capabilities.delete)}
629
+ code={code}
630
+ name={name}
631
+ onClear={() => setSelectedRowKeys([])}
632
+ onCompleted={() => state.list.query.refetch()}
633
+ rows={selectedRows}
634
+ surface={surface}
635
+ writableFields={batchWritableFields}
636
+ />
379
637
  <Table<ResourceRecord>
380
638
  columns={columns}
381
639
  dataSource={rows}
382
640
  loading={state.list.query.isFetching}
383
641
  locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
384
642
  rowKey="id"
385
- 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' }); }}
386
- pagination={{ current: state.page, pageSize: state.pageSize, total: state.list.result.total || 0, showSizeChanger: true, onChange: (next, size) => { state.setPage(next); state.setPageSize(size); } }}
643
+ rowSelection={
644
+ canBatch
645
+ ? {
646
+ selectedRowKeys,
647
+ onChange: (keys) => {
648
+ const next = keys.map(String);
649
+ if (next.length > 100) message.warning('单次最多选择 100 条记录');
650
+ setSelectedRowKeys(next.slice(0, 100));
651
+ },
652
+ }
653
+ : undefined
654
+ }
655
+ scroll={{ x: 'max-content' }}
656
+ size={density}
657
+ onChange={(_, __, sorter) => {
658
+ const item = Array.isArray(sorter) ? sorter[0] : sorter;
659
+ if (item?.field && item.order)
660
+ state.setSort({
661
+ field: String(item.field),
662
+ order: item.order === 'ascend' ? 'asc' : 'desc',
663
+ });
664
+ }}
665
+ pagination={{
666
+ current: state.page,
667
+ pageSize: state.pageSize,
668
+ total: state.list.result.total || 0,
669
+ showSizeChanger: true,
670
+ onChange: (next, size) => {
671
+ state.setPage(next);
672
+ state.setPageSize(size);
673
+ },
674
+ }}
387
675
  />
388
676
  </ResourceListPage>
389
677
  );
@@ -410,27 +698,113 @@ function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }
410
698
  }
411
699
  };
412
700
  return (
413
- <MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} onExport={() => void handleExport()} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
701
+ <MobileResourceListPage
702
+ createCapability={capabilities.create}
703
+ createPath={`/${code}/new`}
704
+ description={`${code} 资源`}
705
+ onExport={() => void handleExport()}
706
+ readCapability={capabilities.read}
707
+ resource={code}
708
+ surface={surface}
709
+ title={name}
710
+ >
414
711
  {state.list.query.isError && (
415
712
  <Alert
416
- action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
713
+ action={
714
+ <Button onClick={() => void state.list.query.refetch()} size="small">
715
+ 重试
716
+ </Button>
717
+ }
417
718
  description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
418
- message="数据读取失败"
419
719
  showIcon
720
+ title="数据读取失败"
420
721
  type="error"
421
722
  />
422
723
  )}
423
724
  <div className="oxa-mobile-filter">
424
- <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); }} />
425
- <Button loading={state.list.query.isFetching} icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
725
+ <Input
726
+ allowClear
727
+ placeholder="关键词"
728
+ value={state.draft.keyword}
729
+ onChange={(event) => state.setDraft((item) => ({ ...item, keyword: event.target.value }))}
730
+ onPressEnter={() => {
731
+ state.setPage(1);
732
+ state.setQuery(state.draft);
733
+ }}
734
+ />
735
+ <Button
736
+ loading={state.list.query.isFetching}
737
+ icon={<SearchOutlined />}
738
+ onClick={() => {
739
+ state.setPage(1);
740
+ state.setQuery(state.draft);
741
+ }}
742
+ type="primary"
743
+ >
744
+ 查询
745
+ </Button>
746
+ </div>
747
+ {state.list.query.isFetching ? (
748
+ <div className="oxa-page-loading">
749
+ <Spin />
750
+ </div>
751
+ ) : rows.length ? (
752
+ <div className="oxa-mobile-record-list">
753
+ {rows.map((record) => (
754
+ <Card className="oxa-mobile-record-card" key={record.id} size="small">
755
+ <div className="oxa-mobile-record-heading">
756
+ <a onClick={() => navigate(`/${code}/${record.id}`)}>{String(record[fields[0]?.key || 'id'] || '-')}</a>
757
+ </div>
758
+ <div className="oxa-mobile-record-fields">
759
+ {fields.slice(1).map((field) => (
760
+ <div key={field.key}>
761
+ <Typography.Text type="secondary">{field.label}</Typography.Text>
762
+ <div>
763
+ <SurfaceFieldValue field={field} value={record[field.key]} />
764
+ </div>
765
+ </div>
766
+ ))}
767
+ </div>
768
+ <Space>
769
+ <Button onClick={() => navigate(`/${code}/${record.id}`)} size="small">
770
+ 查看
771
+ </Button>
772
+ {hasCapability(capabilities.update) && (
773
+ <Button onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small">
774
+ 编辑
775
+ </Button>
776
+ )}
777
+ </Space>
778
+ </Card>
779
+ ))}
780
+ </div>
781
+ ) : (
782
+ <Card>
783
+ <Empty description={`暂无${name}`} />
784
+ </Card>
785
+ )}
786
+ <div className="oxa-mobile-pagination">
787
+ <Button disabled={state.page <= 1} onClick={() => state.setPage((item) => item - 1)}>
788
+ 上一页
789
+ </Button>
790
+ <Typography.Text>第 {state.page} 页</Typography.Text>
791
+ <Button disabled={rows.length < (surface.list?.defaultPageSize || 20)} onClick={() => state.setPage((item) => item + 1)}>
792
+ 下一页
793
+ </Button>
426
794
  </div>
427
- {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>}
428
- <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>
429
795
  </MobileResourceListPage>
430
796
  );
431
797
  }
432
798
 
433
- function GeneratedResourceFormPage({ definition, mode, variant }: { definition: GeneratedDefinition; mode: 'create' | 'edit'; variant: 'desktop' | 'mobile' }) {
799
+ function GeneratedResourceFormPage({
800
+ definition,
801
+ mode,
802
+ variant,
803
+ }: {
804
+ definition: GeneratedDefinition;
805
+ mode: 'create' | 'edit';
806
+ variant: 'desktop' | 'mobile';
807
+ }) {
434
808
  const { id = '' } = useParams();
435
809
  const navigate = useNavigate();
436
810
  const { message } = App.useApp();
@@ -438,32 +812,97 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
438
812
  const [form] = Form.useForm();
439
813
  const create = useCreate<ResourceRecord>();
440
814
  const update = useUpdate<ResourceRecord>();
441
- const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { enabled: mode === 'edit' } });
815
+ const query = useOne<ResourceRecord>({
816
+ resource: definition.code,
817
+ id,
818
+ queryOptions: { enabled: mode === 'edit' },
819
+ });
442
820
  const record = query.result;
443
821
  const { code, name, surface, capabilities } = definition;
444
- useEffect(() => { if (record) form.setFieldsValue(normalizeRecordForForm(record, surface)); }, [form, record, surface]);
822
+ useEffect(() => {
823
+ if (record) form.setFieldsValue(normalizeRecordForForm(record, surface));
824
+ }, [form, record, surface]);
445
825
  const listPath = `/${code}`;
446
826
  const detailPath = `/${code}/${id}`;
447
827
  const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
448
828
  const FieldControl = variant === 'mobile' ? MobileSurfaceFieldControl : SurfaceFieldControl;
449
- if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update)) return <Result status="403" title="当前平台用户无此操作权限" />;
829
+ if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update))
830
+ return <Result status="403" title="当前平台用户无此操作权限" />;
450
831
  const submit = async (values: Record<string, unknown>) => {
451
832
  const next = normalizeFormValues(
452
833
  Object.fromEntries(
453
834
  Object.entries(values).filter(([key]) =>
454
- fieldWritable(surface.fields[key] ? { key, ...surface.fields[key] } : { key, label: key, widget: 'text' }, mode, hasCapability, identity.isAppSuperAdmin)
835
+ fieldWritable(
836
+ surface.fields[key] ? { key, ...surface.fields[key] } : { key, label: key, widget: 'text' },
837
+ mode,
838
+ hasCapability,
839
+ identity.isAppSuperAdmin
840
+ )
455
841
  )
456
842
  ),
457
843
  surface
458
844
  );
459
- if (mode === 'create') { await create.mutateAsync({ resource: code, values: next }); message.success(`${name}已创建`); navigate(listPath); }
460
- else { const result = await update.mutateAsync({ resource: code, id, values: next, meta: { expectedRevision: record?.revision } }); message.success('修改已保存'); navigate(`/${code}/${result.data.id}`); }
845
+ if (mode === 'create') {
846
+ await create.mutateAsync({ resource: code, values: next });
847
+ message.success(`${name}已创建`);
848
+ navigate(listPath);
849
+ } else {
850
+ const result = await update.mutateAsync({
851
+ resource: code,
852
+ id,
853
+ values: next,
854
+ meta: { expectedRevision: record?.revision },
855
+ });
856
+ message.success('修改已保存');
857
+ navigate(`/${code}/${result.data.id}`);
858
+ }
461
859
  };
462
860
  return (
463
- <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}>
464
- <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>)}>
465
- <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>
466
- <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>
861
+ <Page
862
+ backLabel={mode === 'edit' ? `返回${name}详情` : `返回${name}`}
863
+ backPath={mode === 'edit' ? detailPath : listPath}
864
+ capability={mode === 'create' ? capabilities.create : capabilities.update}
865
+ detailPath={record ? detailPath : undefined}
866
+ loading={mode === 'edit' && query.query.isFetching}
867
+ mode={mode}
868
+ notFound={mode === 'edit' && !record}
869
+ resource={code}
870
+ surface={surface}
871
+ title={name}
872
+ >
873
+ <Form
874
+ className={variant === 'mobile' ? 'oxa-mobile-form' : 'oxa-form-full'}
875
+ form={form}
876
+ initialValues={undefined}
877
+ layout="vertical"
878
+ onFinish={(values) => void submit(values as Record<string, unknown>)}
879
+ >
880
+ <div className="oxa-sections">
881
+ {fieldsBySection(surface).map((group) => (
882
+ <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}>
883
+ <div className="oxa-grid">
884
+ {group.fields.map((field) => (
885
+ <FieldControl
886
+ key={field.key}
887
+ disabled={!fieldWritable(field, mode, hasCapability, identity.isAppSuperAdmin)}
888
+ field={field}
889
+ operation={mode === 'create' ? 'create' : 'update'}
890
+ recordId={record?.id}
891
+ renderers={{
892
+ upload: (current, file, recordId) => createNativeResourceClient(code, surface).upload(current.key, file, recordId),
893
+ }}
894
+ />
895
+ ))}
896
+ </div>
897
+ </Card>
898
+ ))}
899
+ </div>
900
+ <div className="oxa-actions">
901
+ <Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button>
902
+ <Button htmlType="submit" loading={create.mutation.isPending || update.mutation.isPending} type="primary">
903
+ {mode === 'create' ? '保存' : '保存修改'}
904
+ </Button>
905
+ </div>
467
906
  </Form>
468
907
  </Page>
469
908
  );
@@ -472,21 +911,31 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
472
911
  function GeneratedResourceDetailPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
473
912
  const { id = '' } = useParams();
474
913
  const navigate = useNavigate();
475
- const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { retry: false } });
914
+ const query = useOne<ResourceRecord>({
915
+ resource: definition.code,
916
+ id,
917
+ queryOptions: { retry: false },
918
+ });
476
919
  const record = query.result;
477
920
  const [auditEntries, setAuditEntries] = useState<DataAuditEntry[]>([]);
478
921
  const [auditLoading, setAuditLoading] = useState(false);
479
922
  const [auditError, setAuditError] = useState('');
923
+ const [auditExpanded, setAuditExpanded] = useState(false);
924
+ const [auditLoadedFor, setAuditLoadedFor] = useState('');
480
925
  useEffect(() => {
481
- if (!record) return;
926
+ if (!record || !auditExpanded || auditLoadedFor === record.id) return;
482
927
  let active = true;
483
928
  setAuditLoading(true);
484
929
  setAuditError('');
485
- void createNativeResourceClient(definition.code, definition.surface).audit(record.id)
486
- .then(page => {
487
- if (active) setAuditEntries(page.items || []);
930
+ void createNativeResourceClient(definition.code, definition.surface)
931
+ .audit(record.id)
932
+ .then((page) => {
933
+ if (active) {
934
+ setAuditEntries(page.items || []);
935
+ setAuditLoadedFor(record.id);
936
+ }
488
937
  })
489
- .catch(reason => {
938
+ .catch((reason) => {
490
939
  if (!active) return;
491
940
  setAuditEntries([]);
492
941
  setAuditError(reason instanceof Error ? reason.message : String(reason));
@@ -497,9 +946,96 @@ function GeneratedResourceDetailPage({ definition, variant }: { definition: Gene
497
946
  return () => {
498
947
  active = false;
499
948
  };
500
- }, [definition.code, definition.surface, record?.id]);
501
- 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;
502
- 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;
503
- 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 };
504
- return variant === 'mobile' ? <MobileResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</MobileResourceDetailPage> : <ResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</ResourceDetailPage>;
949
+ }, [auditExpanded, auditLoadedFor, definition.code, definition.surface, record]);
950
+ const auditContent = auditError ? (
951
+ <Alert
952
+ action={
953
+ <Button
954
+ onClick={() => {
955
+ setAuditError('');
956
+ setAuditLoadedFor('');
957
+ }}
958
+ size="small"
959
+ >
960
+ 重试
961
+ </Button>
962
+ }
963
+ description={auditError}
964
+ showIcon
965
+ title="变更记录读取失败"
966
+ type="error"
967
+ />
968
+ ) : auditLoading ? (
969
+ <div className="oxa-audit-loading">
970
+ <Spin size="small" /> 正在读取操作记录
971
+ </div>
972
+ ) : auditEntries.length ? (
973
+ auditEntries.slice(0, 5).map((entry) => <AuditEntry entry={entry} key={entry.id} surface={definition.surface} />)
974
+ ) : (
975
+ <Typography.Text type="secondary">暂无变更记录</Typography.Text>
976
+ );
977
+ const content = record ? (
978
+ <div className="oxa-detail-layout">
979
+ <div className="oxa-detail-main">
980
+ {fieldsBySection(definition.surface).map((group) => (
981
+ <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}>
982
+ <Descriptions
983
+ column={{ xs: 1, sm: 2, lg: 3 }}
984
+ items={group.fields.map((field) => ({
985
+ key: field.key,
986
+ label: field.label,
987
+ children: <SurfaceFieldValue field={field} value={record[field.key]} />,
988
+ }))}
989
+ />
990
+ </Card>
991
+ ))}
992
+ <div id={`${definition.code}-audit`}>
993
+ <Collapse
994
+ activeKey={auditExpanded ? ['audit'] : []}
995
+ className="oxa-audit-collapse"
996
+ expandIconPlacement="end"
997
+ items={[
998
+ {
999
+ key: 'audit',
1000
+ label: (
1001
+ <div className="oxa-audit-collapse-heading">
1002
+ <strong>操作记录</strong>
1003
+ <span>查看创建、更新和删除的字段变化</span>
1004
+ </div>
1005
+ ),
1006
+ children: auditContent,
1007
+ },
1008
+ ]}
1009
+ onChange={(keys) => setAuditExpanded((Array.isArray(keys) ? keys : [keys]).includes('audit'))}
1010
+ />
1011
+ </div>
1012
+ </div>
1013
+ </div>
1014
+ ) : null;
1015
+ const hero = record ? (
1016
+ <div>
1017
+ <Typography.Title level={variant === 'mobile' ? 3 : 2}>{definition.name}详情</Typography.Title>
1018
+ </div>
1019
+ ) : null;
1020
+ const props = {
1021
+ auditTargetId: `${definition.code}-audit`,
1022
+ backLabel: `返回${definition.name}`,
1023
+ backPath: `/${definition.code}`,
1024
+ editCapability: definition.capabilities.update,
1025
+ editPath: `/${definition.code}/${id}/edit`,
1026
+ error: query.query.isError || (!query.query.isFetching && !record) ? query.query.error?.message || '记录不存在' : undefined,
1027
+ hero,
1028
+ loading: query.query.isFetching,
1029
+ meta: record ? <span>最近更新 {record.updated_at ? new Date(record.updated_at).toLocaleString('zh-CN') : '-'}</span> : null,
1030
+ onAuditOpen: () => setAuditExpanded(true),
1031
+ readCapability: definition.capabilities.read,
1032
+ resource: definition.code,
1033
+ surface: definition.surface,
1034
+ title: definition.name,
1035
+ };
1036
+ return variant === 'mobile' ? (
1037
+ <MobileResourceDetailPage {...props}>{content}</MobileResourceDetailPage>
1038
+ ) : (
1039
+ <ResourceDetailPage {...props}>{content}</ResourceDetailPage>
1040
+ );
505
1041
  }